09c30f97e9
Consolidated duplicated helpers into Elysium.Common.ps1: - Settings parsing (Read-KeyValueSettingsFile, Read-ElysiumSettings, Get-SettingsValue) - Azure Blob URI builder (Build-BlobUri) - S3 SigV4 signing helpers and AWS module bootstrap - AD credential validation and replication permission pre-check - Parallel execution helper (Get-FunctionDefinitionText) Test-WeakADPasswords.ps1 and Extract-NTHashes.ps1 now import Elysium.Common.ps1 for the first time. Update-KHDB.ps1 and Prepare-KHDBStorage.ps1 removed their local duplicates. Deleted legacy Settings.ps1 (superseded by ElysiumSettings.txt). Removed stray placeholder comment in Elysium.ps1. All versions bumped to unified v2.2.1.
708 lines
29 KiB
PowerShell
708 lines
29 KiB
PowerShell
##################################################
|
|
## ____ ___ ____ _____ _ _ _____ _____ ##
|
|
## / ___/ _ \| _ \| ____| | \ | | ____|_ _| ##
|
|
## | | | | | | |_) | _| | \| | _| | | ##
|
|
## | |__| |_| | _ <| |___ _| |\ | |___ | | ##
|
|
## \____\__\_\_| \_\_____(_)_| \_|_____| |_| ##
|
|
## Move fast and fix things. ##
|
|
##################################################
|
|
## Project: Elysium ##
|
|
## File: Test-WeakADPasswords.ps1 ##
|
|
## Version: 2.2.1 ##
|
|
## Support: support@cqre.net ##
|
|
##################################################
|
|
|
|
<#
|
|
#Requires -Modules DSInternals, ActiveDirectory
|
|
.SYNOPSIS
|
|
Weak AD password finder component of Elysium tool.
|
|
|
|
.DESCRIPTION
|
|
This script will test the passwords of selected domain (defined in ElysiumSettings.txt) using DSInternals' Test-PasswordQuality cmdlet. It writes its output to a report file which is meant to be shared with the internal security team. The report now includes UPNs for each account mentioned.
|
|
#>
|
|
|
|
$ErrorActionPreference = 'Stop'
|
|
Set-StrictMode -Version Latest
|
|
|
|
[string]$commonHelper = Join-Path -Path $PSScriptRoot -ChildPath 'Elysium.Common.ps1'
|
|
if (-not (Test-Path -LiteralPath $commonHelper)) { throw "Common helper not found at $commonHelper" }
|
|
. $commonHelper
|
|
|
|
$VerbosePreference = "SilentlyContinue"
|
|
|
|
$scriptRoot = $PSScriptRoot
|
|
|
|
# Ensure consistent UTF-8 output for files across PS5.1/PS7
|
|
try {
|
|
$PSDefaultParameterValues['Out-File:Encoding'] = 'utf8'
|
|
$PSDefaultParameterValues['Set-Content:Encoding'] = 'utf8'
|
|
$PSDefaultParameterValues['Add-Content:Encoding'] = 'utf8'
|
|
# Also align $OutputEncoding for external writes
|
|
$OutputEncoding = New-Object System.Text.UTF8Encoding($false)
|
|
} catch { }
|
|
|
|
function Start-TestTranscript {
|
|
param([string]$BasePath)
|
|
try {
|
|
$logsDir = Join-Path -Path $BasePath -ChildPath 'Reports/logs'
|
|
if (-not (Test-Path $logsDir)) { New-Item -Path $logsDir -ItemType Directory -Force | Out-Null }
|
|
$ts = Get-Date -Format 'yyyyMMdd-HHmmss'
|
|
$logPath = Join-Path -Path $logsDir -ChildPath "test-weakad-$ts.log"
|
|
Start-Transcript -Path $logPath -Force | Out-Null
|
|
} catch {
|
|
Write-Warning "Could not start transcript: $($_.Exception.Message)"
|
|
}
|
|
}
|
|
|
|
function Stop-TestTranscript { try { Stop-Transcript | Out-Null } catch {} }
|
|
|
|
function Invoke-UsageBeacon {
|
|
param(
|
|
[string]$Url,
|
|
[string]$Method = 'GET',
|
|
[int]$TimeoutSeconds = 5,
|
|
[string]$InstanceId
|
|
)
|
|
|
|
if ([string]::IsNullOrWhiteSpace($Url)) { return }
|
|
|
|
$normalizedMethod = 'GET'
|
|
if (-not [string]::IsNullOrWhiteSpace($Method)) {
|
|
$normalizedMethod = $Method.ToUpperInvariant()
|
|
}
|
|
if ($normalizedMethod -notin @('GET', 'POST', 'PUT')) {
|
|
$normalizedMethod = 'GET'
|
|
}
|
|
|
|
$requestParams = @{
|
|
Uri = $Url
|
|
Method = $normalizedMethod
|
|
ErrorAction = 'Stop'
|
|
}
|
|
|
|
$invokeWebRequestCmd = $null
|
|
try { $invokeWebRequestCmd = Get-Command -Name Invoke-WebRequest -ErrorAction Stop } catch { }
|
|
if ($invokeWebRequestCmd -and $invokeWebRequestCmd.Parameters.ContainsKey('UseBasicParsing')) {
|
|
$requestParams['UseBasicParsing'] = $true
|
|
}
|
|
if ($TimeoutSeconds -gt 0 -and $invokeWebRequestCmd -and $invokeWebRequestCmd.Parameters.ContainsKey('TimeoutSec')) {
|
|
$requestParams['TimeoutSec'] = $TimeoutSeconds
|
|
}
|
|
if (-not [string]::IsNullOrWhiteSpace($InstanceId)) {
|
|
$requestParams['Headers'] = @{ 'X-Elysium-Instance' = $InstanceId }
|
|
}
|
|
|
|
if ($normalizedMethod -in @('POST', 'PUT')) {
|
|
$payload = [ordered]@{
|
|
script = 'Test-WeakADPasswords'
|
|
version = '2.2.1'
|
|
ranAtUtc = (Get-Date).ToUniversalTime().ToString('o')
|
|
}
|
|
if (-not [string]::IsNullOrWhiteSpace($InstanceId)) {
|
|
$payload['instanceId'] = $InstanceId
|
|
}
|
|
$requestParams['ContentType'] = 'application/json'
|
|
$requestParams['Body'] = ($payload | ConvertTo-Json -Depth 3 -Compress)
|
|
}
|
|
|
|
try {
|
|
Invoke-WebRequest @requestParams | Out-Null
|
|
Write-Verbose ("Usage beacon sent via {0}." -f $normalizedMethod)
|
|
} catch {
|
|
Write-Verbose ("Usage beacon failed: {0}" -f $_.Exception.Message)
|
|
}
|
|
}
|
|
|
|
# Current timestamp for both report generation and header
|
|
$timestamp = Get-Date -Format "yyyyMMdd-HHmmss"
|
|
|
|
# Define Header and Footer for the report with dynamic date
|
|
$header = @"
|
|
=========== Elysium Report ==========
|
|
Report Generated: $(Get-Date -Format "yyyy-MM-dd HH:mm:ss")
|
|
=====================================
|
|
|
|
"@
|
|
$footer = "`r`n==== End of Report ===="
|
|
|
|
Start-TestTranscript -BasePath $scriptRoot
|
|
try {
|
|
Write-Verbose "Loading settings..."
|
|
$ElysiumSettings = Read-ElysiumSettings -ScriptRoot $scriptRoot
|
|
Write-Verbose "Settings loaded successfully."
|
|
|
|
$usageBeaconUrl = $ElysiumSettings['UsageBeaconUrl']
|
|
$usageBeaconMethod = $ElysiumSettings['UsageBeaconMethod']
|
|
$usageBeaconInstanceId = $ElysiumSettings['UsageBeaconInstanceId']
|
|
$usageBeaconTimeoutSeconds = $null
|
|
if ($ElysiumSettings.ContainsKey('UsageBeaconTimeoutSeconds')) {
|
|
$parsedTimeout = 0
|
|
if ([int]::TryParse($ElysiumSettings['UsageBeaconTimeoutSeconds'], [ref]$parsedTimeout)) {
|
|
$usageBeaconTimeoutSeconds = $parsedTimeout
|
|
}
|
|
}
|
|
if (-not [string]::IsNullOrWhiteSpace($usageBeaconUrl)) {
|
|
$beaconParams = @{ Url = $usageBeaconUrl }
|
|
if (-not [string]::IsNullOrWhiteSpace($usageBeaconMethod)) {
|
|
$beaconParams['Method'] = $usageBeaconMethod
|
|
}
|
|
if (-not [string]::IsNullOrWhiteSpace($usageBeaconInstanceId)) {
|
|
$beaconParams['InstanceId'] = $usageBeaconInstanceId
|
|
}
|
|
if ($null -ne $usageBeaconTimeoutSeconds) {
|
|
$beaconParams['TimeoutSeconds'] = $usageBeaconTimeoutSeconds
|
|
}
|
|
Invoke-UsageBeacon @beaconParams
|
|
}
|
|
|
|
# Define the function to extract domain details from settings
|
|
function Get-DomainDetailsFromSettings {
|
|
param (
|
|
[hashtable]$Settings
|
|
)
|
|
|
|
$domainDetails = [ordered]@{}
|
|
$counter = 1
|
|
while ($true) {
|
|
$nameKey = "Domain${counter}Name"
|
|
$dcKey = "Domain${counter}DC"
|
|
if ($Settings.ContainsKey($nameKey)) {
|
|
$domainDetails["$counter"] = @{
|
|
Name = $Settings[$nameKey]
|
|
DC = $Settings[$dcKey]
|
|
}
|
|
$counter++
|
|
}
|
|
else {
|
|
break
|
|
}
|
|
}
|
|
return $domainDetails
|
|
}
|
|
|
|
# Continue with script logic...
|
|
$domainDetails = Get-DomainDetailsFromSettings -Settings $ElysiumSettings
|
|
Write-Verbose ("Domain details extracted: {0}" -f ($domainDetails | ConvertTo-Json))
|
|
|
|
# Import required modules with PowerShell 7 compatibility
|
|
# - On Windows PowerShell: Import normally
|
|
# - On PowerShell 7+ on Windows: Import using -UseWindowsPowerShell if available
|
|
# - On non-Windows Core: not supported for AD/DSInternals
|
|
$runningInPSCore = ($PSVersionTable.PSEdition -eq 'Core')
|
|
$onWindows = ($env:OS -match 'Windows') -or ([System.Environment]::OSVersion.Platform -eq [System.PlatformID]::Win32NT)
|
|
try { $osProductType = (Get-CimInstance -ClassName Win32_OperatingSystem -ErrorAction Stop).ProductType } catch { $osProductType = 1 }
|
|
$isServerOS = $onWindows -and ($osProductType -ne 1)
|
|
|
|
if ($runningInPSCore -and -not $onWindows) {
|
|
throw 'This script requires Windows when running under PowerShell 7 (AD/DSInternals are Windows-only).'
|
|
}
|
|
|
|
function Test-IsFipsPolicyEnabled {
|
|
if (-not $onWindows) { return $false }
|
|
try {
|
|
$fipsReg = Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa\FipsAlgorithmPolicy' -Name Enabled -ErrorAction Stop
|
|
return ([int]$fipsReg.Enabled -eq 1)
|
|
} catch {
|
|
return $false
|
|
}
|
|
}
|
|
|
|
if (Test-IsFipsPolicyEnabled) {
|
|
throw @"
|
|
FIPS policy is enabled on this host (HKLM:\SYSTEM\CurrentControlSet\Control\Lsa\FipsAlgorithmPolicy\Enabled = 1).
|
|
Test-WeakADPasswords uses DSInternals/AD replication operations that are not fully compatible with this policy in this environment.
|
|
|
|
Remediation:
|
|
1. Run this script from a dedicated non-FIPS workstation/jump host.
|
|
2. If approved by your security policy, temporarily disable local FIPS policy for this host, run the test, then re-enable it.
|
|
3. If FIPS must remain enforced, use an alternative fully FIPS-validated workflow/tool for weak password assessment.
|
|
"@
|
|
}
|
|
|
|
function Test-IsAdmin {
|
|
try {
|
|
$wi = [Security.Principal.WindowsIdentity]::GetCurrent()
|
|
$wp = [Security.Principal.WindowsPrincipal]::new($wi)
|
|
return $wp.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
|
|
} catch { return $false }
|
|
}
|
|
|
|
function Ensure-NuGetAndPSGallery {
|
|
try {
|
|
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
|
} catch { }
|
|
try {
|
|
if (-not (Get-PackageProvider -ListAvailable -Name NuGet -ErrorAction SilentlyContinue)) {
|
|
Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force -ErrorAction Stop | Out-Null
|
|
}
|
|
} catch { Write-Verbose ("NuGet provider install warning: {0}" -f $_.Exception.Message) }
|
|
try { Set-PSRepository -Name PSGallery -InstallationPolicy Trusted -ErrorAction Stop } catch { }
|
|
}
|
|
|
|
function Ensure-ADModule {
|
|
if (Get-Module -ListAvailable -Name ActiveDirectory) { return $true }
|
|
Write-Warning "ActiveDirectory module not found."
|
|
$resp = Read-Host "Install Active Directory PowerShell tools now? [Y/N]"
|
|
if ($resp -notmatch '^(?i:y|yes)$') { return $false }
|
|
if (-not (Test-IsAdmin)) { Write-Error 'Administrator rights are required to install AD tools.'; return $false }
|
|
|
|
$installed = $false
|
|
try {
|
|
if (Get-Command -Name Install-WindowsFeature -ErrorAction SilentlyContinue) {
|
|
try { Import-Module ServerManager -ErrorAction SilentlyContinue } catch {}
|
|
Install-WindowsFeature -Name RSAT-AD-PowerShell -IncludeAllSubFeature -IncludeManagementTools -ErrorAction Stop | Out-Null
|
|
$installed = $true
|
|
} elseif (Get-Command -Name Add-WindowsCapability -ErrorAction SilentlyContinue) {
|
|
$cap = Get-WindowsCapability -Online | Where-Object { $_.Name -like 'Rsat.ActiveDirectory.DS-LDS.Tools*' } | Select-Object -First 1
|
|
if ($cap) {
|
|
Add-WindowsCapability -Online -Name $cap.Name -ErrorAction Stop | Out-Null
|
|
$installed = $true
|
|
} else {
|
|
Write-Warning 'Could not locate RSAT ActiveDirectory capability.'
|
|
}
|
|
} else {
|
|
Write-Warning 'No supported mechanism found to install AD tools automatically on this OS.'
|
|
}
|
|
} catch {
|
|
Write-Error ("Failed to install AD tools: {0}" -f $_.Exception.Message)
|
|
}
|
|
if ($installed -and (Get-Module -ListAvailable -Name ActiveDirectory)) { return $true }
|
|
return $false
|
|
}
|
|
|
|
function Ensure-DSInternalsModule {
|
|
# On PS7+Windows we prefer installing into WindowsPowerShell modules so -UseWindowsPowerShell can load it
|
|
$needWinPSPath = ($runningInPSCore -and $onWindows)
|
|
$winPSModulesPath = Join-Path $env:ProgramFiles 'WindowsPowerShell\Modules'
|
|
|
|
$hasModule = $false
|
|
if ($needWinPSPath) {
|
|
# Quick check for presence in WinPS path
|
|
$candidatePath = Join-Path $winPSModulesPath 'DSInternals'
|
|
if (Test-Path $candidatePath) { $hasModule = $true }
|
|
} else {
|
|
$hasModule = [bool](Get-Module -ListAvailable -Name DSInternals)
|
|
}
|
|
if ($hasModule) { return $true }
|
|
|
|
Write-Warning "DSInternals module not found."
|
|
$resp = Read-Host "Install DSInternals from PowerShell Gallery now? [Y/N]"
|
|
if ($resp -notmatch '^(?i:y|yes)$') { return $false }
|
|
if (-not (Test-IsAdmin)) { Write-Error 'Administrator rights are required to install modules.'; return $false }
|
|
|
|
try {
|
|
Ensure-NuGetAndPSGallery
|
|
if ($needWinPSPath) {
|
|
if (-not (Test-Path $winPSModulesPath)) { New-Item -Path $winPSModulesPath -ItemType Directory -Force | Out-Null }
|
|
Save-Module -Name DSInternals -Path $winPSModulesPath -Force -ErrorAction Stop
|
|
} else {
|
|
Install-Module -Name DSInternals -Scope AllUsers -Force -ErrorAction Stop
|
|
}
|
|
} catch {
|
|
Write-Error ("Failed to install DSInternals: {0}" -f $_.Exception.Message)
|
|
return $false
|
|
}
|
|
|
|
if ($needWinPSPath) {
|
|
return (Test-Path (Join-Path $winPSModulesPath 'DSInternals'))
|
|
} else {
|
|
return [bool](Get-Module -ListAvailable -Name DSInternals)
|
|
}
|
|
}
|
|
|
|
function Import-CompatModule {
|
|
param(
|
|
[Parameter(Mandatory)][string]$Name
|
|
)
|
|
$params = @{ Name = $Name; ErrorAction = 'Stop' }
|
|
if ($runningInPSCore -and $onWindows) {
|
|
$importCmd = Get-Command -Name Import-Module -CommandType Cmdlet -ErrorAction SilentlyContinue
|
|
if ($importCmd -and $importCmd.Parameters.ContainsKey('UseWindowsPowerShell')) {
|
|
$params['UseWindowsPowerShell'] = $true
|
|
}
|
|
}
|
|
if ($Name -eq 'DSInternals') {
|
|
# DSInternals can emit a FIPS MD5 warning via Write-Error during import; treat it as non-fatal if the module loads.
|
|
$params['ErrorAction'] = 'SilentlyContinue'
|
|
$importErrors = @()
|
|
try {
|
|
Import-Module @params -ErrorVariable +importErrors
|
|
} catch {
|
|
$fqid = [string]$_.FullyQualifiedErrorId
|
|
$message = $_.Exception.Message
|
|
$isFipsBootstrapError = ($fqid -match 'DSInternals\.Bootstrap\.psm1') -and ($message -match 'Only FIPS certified cryptographic algorithms are enabled in \.NET')
|
|
if (-not $isFipsBootstrapError) { throw }
|
|
Write-Warning "DSInternals bootstrap reported FIPS restrictions. Continuing if the module is available."
|
|
}
|
|
|
|
$moduleLoaded = [bool](Get-Module -Name $Name -ErrorAction SilentlyContinue)
|
|
if (-not $moduleLoaded) {
|
|
$fipsErrorSeen = @($importErrors | Where-Object { $_.Exception.Message -match 'Only FIPS certified cryptographic algorithms are enabled in \.NET' }).Count -gt 0
|
|
if ($fipsErrorSeen) {
|
|
throw "DSInternals could not be loaded under current FIPS policy. Use a host/policy that allows required algorithms for DSInternals."
|
|
}
|
|
if ($importErrors.Count -gt 0) { throw $importErrors[0] }
|
|
throw "Failed to import module '$Name'."
|
|
}
|
|
|
|
$fipsErrors = @($importErrors | Where-Object { $_.Exception.Message -match 'Only FIPS certified cryptographic algorithms are enabled in \.NET' })
|
|
if ($fipsErrors.Count -gt 0) {
|
|
Write-Warning "DSInternals loaded under FIPS policy. MD5-dependent DSInternals checks may be limited."
|
|
}
|
|
|
|
$nonFipsErrors = @($importErrors | Where-Object { $_.Exception.Message -notmatch 'Only FIPS certified cryptographic algorithms are enabled in \.NET' })
|
|
if ($nonFipsErrors.Count -gt 0) {
|
|
Write-Warning ("DSInternals import reported non-fatal warning(s): {0}" -f $nonFipsErrors[0].Exception.Message)
|
|
}
|
|
|
|
Write-Verbose ("Imported module '{0}' (Core={1}, Windows={2})" -f $Name, $runningInPSCore, $onWindows)
|
|
return
|
|
}
|
|
|
|
Import-Module @params
|
|
Write-Verbose ("Imported module '{0}' (Core={1}, Windows={2})" -f $Name, $runningInPSCore, $onWindows)
|
|
}
|
|
|
|
try {
|
|
Import-CompatModule -Name 'ActiveDirectory'
|
|
} catch {
|
|
Write-Warning ("ActiveDirectory import failed: {0}" -f $_.Exception.Message)
|
|
if (Ensure-ADModule) {
|
|
Import-CompatModule -Name 'ActiveDirectory'
|
|
} else {
|
|
throw "Failed to import ActiveDirectory module. On PS7, ensure RSAT AD tools are installed and Windows PowerShell 5.1 is present."
|
|
}
|
|
}
|
|
|
|
try {
|
|
Import-CompatModule -Name 'DSInternals'
|
|
} catch {
|
|
Write-Warning ("DSInternals import failed: {0}" -f $_.Exception.Message)
|
|
if (Ensure-DSInternalsModule) {
|
|
Import-CompatModule -Name 'DSInternals'
|
|
} else {
|
|
throw "Failed to import DSInternals module. Try installing from PSGallery or placing it under '$env:ProgramFiles\WindowsPowerShell\Modules'."
|
|
}
|
|
}
|
|
|
|
# Resolve KHDB path with fallbacks
|
|
$installationPath = $ElysiumSettings["InstallationPath"]
|
|
if ([string]::IsNullOrWhiteSpace($installationPath)) { $installationPath = $scriptRoot }
|
|
elseif (-not [System.IO.Path]::IsPathRooted($installationPath)) { $installationPath = Join-Path -Path $scriptRoot -ChildPath $installationPath }
|
|
|
|
$khdbName = if ([string]::IsNullOrWhiteSpace($ElysiumSettings["WeakPasswordsDatabase"])) { 'khdb.txt' } else { $ElysiumSettings["WeakPasswordsDatabase"] }
|
|
$WeakHashesSortedFilePath = Join-Path -Path $installationPath -ChildPath $khdbName
|
|
if (-not (Test-Path $WeakHashesSortedFilePath)) {
|
|
Write-Error "Weak password hashes file not found at '$WeakHashesSortedFilePath'."
|
|
exit
|
|
}
|
|
Write-Verbose "Weak password hashes file found at '$WeakHashesSortedFilePath'."
|
|
|
|
# Ensure the report directory exists (relative paths resolved against script root)
|
|
$reportPathBase = $ElysiumSettings["ReportPathBase"]
|
|
if ([string]::IsNullOrWhiteSpace($reportPathBase)) { $reportPathBase = 'Reports' }
|
|
if (-not [System.IO.Path]::IsPathRooted($reportPathBase)) { $reportPathBase = Join-Path -Path $scriptRoot -ChildPath $reportPathBase }
|
|
if (-not (Test-Path -Path $reportPathBase)) {
|
|
try {
|
|
New-Item -Path $reportPathBase -ItemType Directory -ErrorAction Stop | Out-Null
|
|
Write-Verbose "Report directory created at '$reportPathBase'."
|
|
} catch {
|
|
Write-Error ("Failed to create report directory: {0}" -f $_.Exception.Message)
|
|
exit
|
|
}
|
|
}
|
|
|
|
# Read filtering flag (defaults to false)
|
|
$checkOnlyEnabledUsers = $false
|
|
if ($ElysiumSettings.ContainsKey('CheckOnlyEnabledUsers')) {
|
|
try { $checkOnlyEnabledUsers = [System.Convert]::ToBoolean($ElysiumSettings['CheckOnlyEnabledUsers']) } catch { $checkOnlyEnabledUsers = $false }
|
|
}
|
|
|
|
# Function to get UPN for a given SAM account name
|
|
function Get-UserUPN {
|
|
param (
|
|
[string]$SamAccountName,
|
|
[string]$Domain,
|
|
[System.Management.Automation.PSCredential]$Credential
|
|
)
|
|
|
|
Write-Verbose "Attempting to get UPN for $SamAccountName in domain $Domain"
|
|
try {
|
|
# Remove domain prefix if exists
|
|
$simplifiedSamAccountName = $SamAccountName -replace '^.*\\', ''
|
|
|
|
$user = Get-ADUser -Identity $simplifiedSamAccountName -Properties UserPrincipalName -Server $Domain -Credential $Credential
|
|
Write-Verbose "UPN found: $($user.UserPrincipalName)"
|
|
return $user.UserPrincipalName
|
|
} catch {
|
|
Write-Verbose ("Failed to get UPN for {0}: {1}" -f $SamAccountName, $_.Exception.Message)
|
|
return "UPN not found"
|
|
}
|
|
}
|
|
|
|
# (removed stray top-level loop; UPN enrichment happens during report generation below)
|
|
|
|
function Resolve-DSInternalsWeakHashFile {
|
|
param(
|
|
[Parameter(Mandatory)][string]$Path
|
|
)
|
|
|
|
if (-not (Test-Path -LiteralPath $Path)) {
|
|
throw "Weak password hashes file not found at '$Path'."
|
|
}
|
|
|
|
$compatibleRegex = '^[0-9A-F]{32}$'
|
|
$legacyRegex = '^[0-9A-Fa-f]{32}(:\d+)?$'
|
|
$lineNumber = 0
|
|
$previousHash = $null
|
|
$duplicateCount = 0
|
|
$legacyEntryCount = 0
|
|
$needsNormalization = $false
|
|
$reader = $null
|
|
|
|
try {
|
|
$reader = New-Object System.IO.StreamReader($Path, [System.Text.Encoding]::UTF8, $true)
|
|
while (($line = $reader.ReadLine()) -ne $null) {
|
|
$lineNumber++
|
|
$trimmed = $line.Trim()
|
|
if ($trimmed.Length -eq 0) { continue }
|
|
|
|
if ($trimmed -notmatch $legacyRegex) {
|
|
throw ("Weak password hashes file '{0}' contains invalid content at line {1}: '{2}'." -f $Path, $lineNumber, $trimmed)
|
|
}
|
|
|
|
if ($trimmed -notmatch $compatibleRegex) {
|
|
$needsNormalization = $true
|
|
if ($trimmed.Contains(':')) { $legacyEntryCount++ }
|
|
}
|
|
|
|
$normalizedHash = ($trimmed.Split(':', 2)[0]).ToUpperInvariant()
|
|
if ($line -cne $trimmed -or $trimmed -cne $normalizedHash) {
|
|
$needsNormalization = $true
|
|
}
|
|
|
|
if ($null -ne $previousHash) {
|
|
if ($normalizedHash -lt $previousHash) {
|
|
throw "Weak password hashes file '$Path' is not sorted alphabetically at line $lineNumber."
|
|
}
|
|
if ($normalizedHash -eq $previousHash) {
|
|
$duplicateCount++
|
|
$needsNormalization = $true
|
|
}
|
|
}
|
|
|
|
$previousHash = $normalizedHash
|
|
}
|
|
} finally {
|
|
if ($reader) { $reader.Dispose() }
|
|
}
|
|
|
|
if (-not $needsNormalization) {
|
|
return [pscustomobject]@{
|
|
Path = $Path
|
|
IsTemporary = $false
|
|
}
|
|
}
|
|
|
|
$tmpPath = [System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), ('elysium-khdb-' + [System.Guid]::NewGuid().ToString() + '.txt'))
|
|
$encoding = New-Object System.Text.UTF8Encoding($false)
|
|
$reader = $null
|
|
$writer = $null
|
|
$lastWrittenHash = $null
|
|
|
|
try {
|
|
$reader = New-Object System.IO.StreamReader($Path, [System.Text.Encoding]::UTF8, $true)
|
|
$writer = New-Object System.IO.StreamWriter($tmpPath, $false, $encoding)
|
|
|
|
while (($line = $reader.ReadLine()) -ne $null) {
|
|
$trimmed = $line.Trim()
|
|
if ($trimmed.Length -eq 0) { continue }
|
|
|
|
$normalizedHash = ($trimmed.Split(':', 2)[0]).ToUpperInvariant()
|
|
if ($normalizedHash -eq $lastWrittenHash) { continue }
|
|
|
|
$writer.WriteLine($normalizedHash)
|
|
$lastWrittenHash = $normalizedHash
|
|
}
|
|
} finally {
|
|
if ($reader) { $reader.Dispose() }
|
|
if ($writer) { $writer.Dispose() }
|
|
}
|
|
|
|
$normalizationReasons = @()
|
|
if ($legacyEntryCount -gt 0) { $normalizationReasons += "$legacyEntryCount legacy HASH:count entries" }
|
|
if ($duplicateCount -gt 0) { $normalizationReasons += "$duplicateCount duplicate hashes" }
|
|
if ($normalizationReasons.Count -eq 0) { $normalizationReasons += 'format normalization' }
|
|
Write-Warning ("Normalized weak password hashes file for DSInternals compatibility ({0}). Temporary file: {1}" -f ($normalizationReasons -join ', '), $tmpPath)
|
|
|
|
return [pscustomobject]@{
|
|
Path = $tmpPath
|
|
IsTemporary = $true
|
|
}
|
|
}
|
|
|
|
# Function to test for weak AD passwords
|
|
function Test-WeakADPasswords {
|
|
param (
|
|
[hashtable]$DomainDetails,
|
|
[string]$FilePath,
|
|
[bool]$CheckOnlyEnabledUsers = $false,
|
|
[System.Management.Automation.PSCredential]$Credential
|
|
)
|
|
|
|
# User selects a domain
|
|
Write-Host "Select a domain to test:"
|
|
$DomainDetails.GetEnumerator() | Sort-Object { [int]$_.Key } | ForEach-Object { Write-Host "$($_.Key): $($_.Value.Name)" }
|
|
$selection = Read-Host "Enter the number of the domain"
|
|
|
|
if (-not ($DomainDetails.ContainsKey($selection))) {
|
|
Write-Error "Invalid selection."
|
|
return
|
|
}
|
|
|
|
$selectedDomain = $DomainDetails[$selection]
|
|
Write-Verbose "Selected domain: $($selectedDomain.Name)"
|
|
|
|
if ([string]::IsNullOrWhiteSpace($selectedDomain["DC"])) {
|
|
Write-Error ("Domain '{0}' does not have a configured DC in ElysiumSettings.txt." -f $selectedDomain.Name)
|
|
return
|
|
}
|
|
|
|
if ($null -eq $Credential) {
|
|
$credential = Get-ValidatedADCredential -DomainName $selectedDomain.Name -Server $selectedDomain["DC"]
|
|
} else {
|
|
$credential = $Credential
|
|
Write-Verbose ("Using credential supplied by caller: {0}" -f $credential.UserName)
|
|
}
|
|
|
|
# Verify the account has the three replication extended rights before attempting DCSync
|
|
try {
|
|
$domainInfo = Get-ADDomain -Server $selectedDomain["DC"] -Credential $credential -ErrorAction Stop
|
|
Test-ReplicationPermissions -DomainDN $domainInfo.DistinguishedName `
|
|
-Server $selectedDomain["DC"] -Credential $credential
|
|
} catch {
|
|
Write-Error $_.Exception.Message
|
|
return
|
|
}
|
|
|
|
# Performing the test
|
|
Write-Verbose "Testing password quality for $($selectedDomain.Name)..."
|
|
$resolvedHashFile = $null
|
|
try {
|
|
$resolvedHashFile = Resolve-DSInternalsWeakHashFile -Path $FilePath
|
|
$accounts = Get-ADReplAccount -All -Server $selectedDomain["DC"] -Credential $credential
|
|
if ($CheckOnlyEnabledUsers) {
|
|
Write-Verbose "Filtering to only enabled users per settings."
|
|
# Prefer property 'Enabled' if present, fall back gracefully if not
|
|
$accounts = $accounts | Where-Object {
|
|
if ($_.PSObject.Properties.Name -contains 'Enabled') { $_.Enabled } else { $true }
|
|
}
|
|
}
|
|
$testResults = $accounts | Test-PasswordQuality -WeakPasswordHashesSortedFile $resolvedHashFile.Path
|
|
Write-Verbose "Password quality test completed."
|
|
} catch {
|
|
$message = $_.Exception.Message
|
|
if ($message -match 'Access is denied') {
|
|
Write-Error ("Access denied while reading replication data from '{0}' using '{1}'. Ensure this account has Replicating Directory Changes, Replicating Directory Changes All, and Replicating Directory Changes In Filtered Set on the domain." -f $selectedDomain["DC"], $credential.UserName)
|
|
return
|
|
}
|
|
if ($message -match 'rejected the client credentials|unknown user name|bad password|logon failure') {
|
|
Write-Error ("Credentials for '{0}' were rejected by '{1}'. Re-run and provide valid domain credentials." -f $credential.UserName, $selectedDomain["DC"])
|
|
return
|
|
}
|
|
Write-Error ("An error occurred while testing passwords: {0}" -f $message)
|
|
return
|
|
} finally {
|
|
if ($resolvedHashFile -and $resolvedHashFile.IsTemporary -and (Test-Path -LiteralPath $resolvedHashFile.Path)) {
|
|
try { Remove-Item -LiteralPath $resolvedHashFile.Path -Force -ErrorAction Stop } catch { }
|
|
}
|
|
}
|
|
|
|
# Report generation with dynamic content and UPNs
|
|
$reportPath = Join-Path -Path $reportPathBase -ChildPath "$($selectedDomain.Name)_WeakPasswordReport_$timestamp.txt"
|
|
$upnOnlyReportPath = Join-Path -Path $reportPathBase -ChildPath "$($selectedDomain.Name)_DictionaryPasswordUPNs_$timestamp.txt"
|
|
|
|
# Build a lookup of SAM account names to UPNs for dictionary hits by leveraging structured results
|
|
$dictionaryLogonNames = @()
|
|
foreach ($result in @($testResults)) {
|
|
if ($null -ne $result -and $null -ne $result.WeakPassword) {
|
|
$dictionaryLogonNames += $result.WeakPassword
|
|
}
|
|
}
|
|
$dictionaryLogonNames = $dictionaryLogonNames | Sort-Object -Unique
|
|
|
|
$dictionarySamToUpn = @{}
|
|
$upnReportContent = @()
|
|
|
|
foreach ($logonName in $dictionaryLogonNames) {
|
|
$samAccountName = $logonName -replace '^.*\\', ''
|
|
if (-not [string]::IsNullOrWhiteSpace($samAccountName) -and -not $dictionarySamToUpn.ContainsKey($samAccountName)) {
|
|
Write-Verbose "Looking up UPN for $samAccountName (dictionary hit)"
|
|
$upn = Get-UserUPN -SamAccountName $samAccountName -Domain $selectedDomain.DC -Credential $credential
|
|
$dictionarySamToUpn[$samAccountName] = $upn
|
|
if ($upn -ne "UPN not found") {
|
|
$upnReportContent += $upn
|
|
}
|
|
}
|
|
}
|
|
|
|
Write-Verbose "Generating report at $reportPath"
|
|
$reportContent = @($header, ($testResults | Out-String).Trim(), $footer) -join "`r`n"
|
|
|
|
$lines = $reportContent -split "`r`n"
|
|
$newReportContent = @()
|
|
$collectingUPNs = $false
|
|
|
|
foreach ($line in $lines) {
|
|
$newReportContent += $line
|
|
|
|
if ($line -match "Passwords of these accounts have been found in the dictionary:") {
|
|
$collectingUPNs = $true
|
|
continue
|
|
}
|
|
|
|
if ($collectingUPNs) {
|
|
if ($line -match '^\s*$') { continue }
|
|
if ($line -match '^\s*-{2,}') { continue }
|
|
if ($line -match '^\s*(SamAccountName|LogonName)\b') { continue }
|
|
if ($line -match '^[^\s].*:\s*$' -and $line -notmatch 'dictionary') {
|
|
$collectingUPNs = $false
|
|
continue
|
|
}
|
|
|
|
$firstToken = ($line.Trim() -split '\s+')[0]
|
|
if (-not [string]::IsNullOrWhiteSpace($firstToken)) {
|
|
$samAccountName = $firstToken -replace '^.*\\', ''
|
|
if ($dictionarySamToUpn.ContainsKey($samAccountName)) {
|
|
$upnValue = $dictionarySamToUpn[$samAccountName]
|
|
$newReportContent += " UPN: $upnValue"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
$updatedReportContent = $newReportContent -join "`r`n"
|
|
$upnOnlyContent = $upnReportContent -join "`r`n"
|
|
|
|
try {
|
|
$updatedReportContent | Out-File -FilePath $reportPath -Encoding utf8 -ErrorAction Stop
|
|
Write-Host "Report saved to $reportPath"
|
|
|
|
$upnOnlyContent | Out-File -FilePath $upnOnlyReportPath -Encoding utf8 -ErrorAction Stop
|
|
Write-Host "UPN-only report saved to $upnOnlyReportPath"
|
|
} catch {
|
|
Write-Error ("Failed to save report: {0}" -f $_.Exception.Message)
|
|
}
|
|
}
|
|
|
|
# Main script logic
|
|
Write-Verbose "Starting main script execution..."
|
|
Test-WeakADPasswords -DomainDetails $domainDetails -FilePath $WeakHashesSortedFilePath -CheckOnlyEnabledUsers:$checkOnlyEnabledUsers
|
|
} catch {
|
|
Write-Error ("An error occurred during script execution: {0}" -f $_.Exception.Message)
|
|
} finally {
|
|
Stop-TestTranscript
|
|
}
|
|
|
|
Write-Host "Script execution completed."
|