27a682a968
Test-ReplicationPermissions now uses the tokenGroups constructed attribute to resolve all effective SIDs in the caller's Kerberos token, including nested group memberships. This replaces the previous MemberOf walk which missed indirect entitlement and could produce false-positive missing-permission errors. All versions bumped to unified v2.2.2.
395 lines
16 KiB
PowerShell
395 lines
16 KiB
PowerShell
$script:ElysiumVersion = '2.2.2'
|
|
|
|
function Invoke-RestartWithExecutable {
|
|
param(
|
|
[string]$ExecutablePath,
|
|
[hashtable]$BoundParameters,
|
|
[object[]]$UnboundArguments
|
|
)
|
|
|
|
if (-not $ExecutablePath) { return }
|
|
if (-not $PSCommandPath) { return }
|
|
|
|
$argList = @('-NoLogo', '-NoProfile', '-File', $PSCommandPath)
|
|
|
|
if ($BoundParameters) {
|
|
foreach ($entry in $BoundParameters.GetEnumerator()) {
|
|
$key = "-$($entry.Key)"
|
|
$value = $entry.Value
|
|
if ($value -is [System.Management.Automation.SwitchParameter]) {
|
|
if ($value.IsPresent) { $argList += $key }
|
|
} else {
|
|
$argList += $key
|
|
$argList += $value
|
|
}
|
|
}
|
|
}
|
|
|
|
if ($UnboundArguments) {
|
|
$argList += $UnboundArguments
|
|
}
|
|
|
|
& $ExecutablePath @argList
|
|
exit $LASTEXITCODE
|
|
}
|
|
|
|
function Restart-WithPwshIfAvailable {
|
|
param(
|
|
[hashtable]$BoundParameters,
|
|
[object[]]$UnboundArguments
|
|
)
|
|
|
|
if ($PSVersionTable.PSVersion.Major -ge 7 -or $PSVersionTable.PSEdition -eq 'Core') { return }
|
|
$pwsh = Get-Command -Name 'pwsh' -ErrorAction SilentlyContinue
|
|
if (-not $pwsh) { return }
|
|
Write-Host ("PowerShell 7 detected at '{0}'; relaunching script under pwsh..." -f $pwsh.Path)
|
|
Invoke-RestartWithExecutable -ExecutablePath $pwsh.Path -BoundParameters $BoundParameters -UnboundArguments $UnboundArguments
|
|
}
|
|
|
|
function Restart-WithWindowsPowerShellIfAvailable {
|
|
param(
|
|
[hashtable]$BoundParameters,
|
|
[object[]]$UnboundArguments
|
|
)
|
|
|
|
if ($PSVersionTable.PSEdition -eq 'Desktop') { return }
|
|
$powershellCmd = Get-Command -Name 'powershell.exe' -ErrorAction SilentlyContinue
|
|
$powershellPath = $null
|
|
if ($powershellCmd) {
|
|
$powershellPath = $powershellCmd.Path
|
|
} else {
|
|
$defaultPath = Join-Path -Path $env:SystemRoot -ChildPath 'System32\WindowsPowerShell\v1.0\powershell.exe'
|
|
if (Test-Path -LiteralPath $defaultPath) {
|
|
$powershellPath = $defaultPath
|
|
}
|
|
}
|
|
if (-not $powershellPath) {
|
|
Write-Warning 'Windows PowerShell (powershell.exe) was not found; continuing under current host.'
|
|
return
|
|
}
|
|
Write-Host ("Windows PowerShell detected at '{0}'; relaunching script under powershell.exe..." -f $powershellPath)
|
|
Invoke-RestartWithExecutable -ExecutablePath $powershellPath -BoundParameters $BoundParameters -UnboundArguments $UnboundArguments
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Settings loading
|
|
# ---------------------------------------------------------------------------
|
|
|
|
function Read-KeyValueSettingsFile {
|
|
param([Parameter(Mandatory)][string]$Path)
|
|
$result = @{}
|
|
if (-not (Test-Path -LiteralPath $Path)) { return $result }
|
|
foreach ($line in (Get-Content -LiteralPath $Path)) {
|
|
if ($null -eq $line) { continue }
|
|
$trimmed = $line.Trim()
|
|
if (-not $trimmed) { continue }
|
|
if ($trimmed.StartsWith('#')) { continue }
|
|
$kv = $line -split '=', 2
|
|
if ($kv.Count -ne 2) { continue }
|
|
$key = $kv[0].Trim()
|
|
$value = $kv[1].Trim()
|
|
if (-not $key) { continue }
|
|
if ($value.StartsWith("'") -and $value.EndsWith("'") -and $value.Length -ge 2) {
|
|
$value = $value.Substring(1, $value.Length - 2)
|
|
}
|
|
$result[$key] = $value
|
|
}
|
|
return $result
|
|
}
|
|
|
|
function Read-ElysiumSettings {
|
|
param([Parameter(Mandatory)][string]$ScriptRoot)
|
|
$settingsPath = Join-Path -Path $ScriptRoot -ChildPath 'ElysiumSettings.txt'
|
|
if (-not (Test-Path -LiteralPath $settingsPath)) {
|
|
throw "Settings file not found at $settingsPath"
|
|
}
|
|
return Read-KeyValueSettingsFile -Path $settingsPath
|
|
}
|
|
|
|
function Get-SettingsValue {
|
|
param(
|
|
[hashtable]$Settings,
|
|
[string]$Key
|
|
)
|
|
if (-not $Settings) { return $null }
|
|
if ($Settings.ContainsKey($Key)) { return $Settings[$Key] }
|
|
return $null
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Parallel execution helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
function Get-FunctionDefinitionText {
|
|
param([Parameter(Mandatory)][string]$Name)
|
|
$cmd = Get-Command -Name $Name -CommandType Function -ErrorAction Stop
|
|
return $cmd.ScriptBlock.Ast.Extent.Text
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Azure Blob Storage helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
function Build-BlobUri {
|
|
param(
|
|
[string]$Account,
|
|
[string]$Container,
|
|
[string]$Sas,
|
|
[string]$BlobName
|
|
)
|
|
|
|
if ([string]::IsNullOrWhiteSpace($Account)) { throw 'storageAccountName is missing or empty.' }
|
|
if ([string]::IsNullOrWhiteSpace($Container)) { throw 'containerName is missing or empty.' }
|
|
if ([string]::IsNullOrWhiteSpace($Sas)) { throw 'sasToken is missing or empty.' }
|
|
if ([string]::IsNullOrWhiteSpace($BlobName)) { throw 'BlobName cannot be empty.' }
|
|
|
|
$sas = $Sas.Trim()
|
|
if (-not $sas.StartsWith('?')) { $sas = '?' + $sas }
|
|
$normalizedBlob = $BlobName.Replace('\', '/').TrimStart('/')
|
|
$builder = [System.UriBuilder]::new("https://$Account.blob.core.windows.net/$Container/$normalizedBlob")
|
|
$builder.Query = $sas.TrimStart('?')
|
|
return $builder.Uri.AbsoluteUri
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Storage path utilities
|
|
# ---------------------------------------------------------------------------
|
|
|
|
function Combine-StoragePath {
|
|
param(
|
|
[string]$Prefix,
|
|
[string]$Name
|
|
)
|
|
|
|
$cleanName = $Name.Replace('\', '/').TrimStart('/')
|
|
if ([string]::IsNullOrWhiteSpace($Prefix)) { return $cleanName }
|
|
$normalizedPrefix = $Prefix.Replace('\', '/').Trim('/')
|
|
if ([string]::IsNullOrEmpty($normalizedPrefix)) { return $cleanName }
|
|
return "$normalizedPrefix/$cleanName"
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# AWS SigV4 / S3 helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
function Get-Bytes([string]$s) { return [System.Text.Encoding]::UTF8.GetBytes($s) }
|
|
|
|
function Get-HashHex([byte[]]$bytes) {
|
|
if ($null -eq $bytes) { $bytes = [byte[]]@() }
|
|
$sha = [System.Security.Cryptography.SHA256]::Create()
|
|
try {
|
|
$ms = New-Object System.IO.MemoryStream -ArgumentList (,$bytes)
|
|
try {
|
|
$hash = $sha.ComputeHash([System.IO.Stream]$ms)
|
|
} finally { $ms.Dispose() }
|
|
return ([BitConverter]::ToString($hash)).Replace('-', '').ToLowerInvariant()
|
|
} finally { $sha.Dispose() }
|
|
}
|
|
|
|
function HmacSha256([byte[]]$key, [string]$data) {
|
|
$h = [System.Security.Cryptography.HMACSHA256]::new($key)
|
|
try {
|
|
$b = [System.Text.Encoding]::UTF8.GetBytes($data)
|
|
$ms = New-Object System.IO.MemoryStream -ArgumentList (,$b)
|
|
try {
|
|
return $h.ComputeHash([System.IO.Stream]$ms)
|
|
} finally { $ms.Dispose() }
|
|
} finally { $h.Dispose() }
|
|
}
|
|
|
|
function GetSignatureKey([string]$secret, [string]$dateStamp, [string]$regionName, [string]$serviceName) {
|
|
$kDate = HmacSha256 (Get-Bytes ('AWS4' + $secret)) $dateStamp
|
|
$kRegion = HmacSha256 $kDate $regionName
|
|
$kService = HmacSha256 $kRegion $serviceName
|
|
HmacSha256 $kService 'aws4_request'
|
|
}
|
|
|
|
function UriEncode([string]$data, [bool]$encodeSlash) {
|
|
if ($null -eq $data) { return '' }
|
|
$enc = [System.Uri]::EscapeDataString($data)
|
|
if (-not $encodeSlash) { $enc = $enc -replace '%2F', '/' }
|
|
return $enc
|
|
}
|
|
|
|
function BuildCanonicalPath([System.Uri]$uri) {
|
|
$segments = $uri.AbsolutePath.Split('/')
|
|
$encoded = @()
|
|
foreach ($s in $segments) { $encoded += (UriEncode $s $false) }
|
|
$path = ($encoded -join '/')
|
|
if (-not $path.StartsWith('/')) { $path = '/' + $path }
|
|
return $path
|
|
}
|
|
|
|
function ToHex([byte[]]$b) { ([BitConverter]::ToString($b)).Replace('-', '').ToLowerInvariant() }
|
|
|
|
function BuildAuthHeaders($method, [System.Uri]$uri, [string]$region, [string]$accessKey, [string]$secretKey, [string]$payloadHash) {
|
|
$algorithm = 'AWS4-HMAC-SHA256'
|
|
$timestamp = (Get-Date).ToUniversalTime()
|
|
$amzDate = $timestamp.ToString('yyyyMMddTHHmmssZ')
|
|
$dateStamp = $timestamp.ToString('yyyyMMdd')
|
|
$hostHeader = $uri.Host
|
|
if (-not $uri.IsDefaultPort) { $hostHeader = "${hostHeader}:$($uri.Port)" }
|
|
$canonicalUri = BuildCanonicalPath $uri
|
|
$canonicalQueryString = ''
|
|
$canonicalHeaders = "host:$hostHeader`n" + "x-amz-content-sha256:$payloadHash`n" + "x-amz-date:$amzDate`n"
|
|
$signedHeaders = 'host;x-amz-content-sha256;x-amz-date'
|
|
$canonicalRequest = "$method`n$canonicalUri`n$canonicalQueryString`n$canonicalHeaders`n$signedHeaders`n$payloadHash"
|
|
$credentialScope = "$dateStamp/$region/s3/aws4_request"
|
|
$stringToSign = "$algorithm`n$amzDate`n$credentialScope`n$((Get-HashHex (Get-Bytes $canonicalRequest)))"
|
|
$signingKey = GetSignatureKey $secretKey $dateStamp $region 's3'
|
|
$signature = ToHex (HmacSha256 $signingKey $stringToSign)
|
|
$authHeader = "$algorithm Credential=$accessKey/$credentialScope, SignedHeaders=$signedHeaders, Signature=$signature"
|
|
@{
|
|
'x-amz-date' = $amzDate
|
|
'x-amz-content-sha256' = $payloadHash
|
|
'Authorization' = $authHeader
|
|
}
|
|
}
|
|
|
|
function BuildS3Uri([string]$endpointUrl, [string]$bucket, [string]$key, [bool]$forcePathStyle) {
|
|
$base = [System.Uri]$endpointUrl
|
|
$builder = [System.UriBuilder]::new($base)
|
|
$normalizedKey = $key.Replace('\', '/').TrimStart('/')
|
|
if ($forcePathStyle) {
|
|
$path = $builder.Path.TrimEnd('/')
|
|
if ([string]::IsNullOrEmpty($path)) { $path = '/' }
|
|
$builder.Path = ($path.TrimEnd('/') + '/' + $bucket + '/' + $normalizedKey)
|
|
} else {
|
|
$builder.Host = "$bucket." + $builder.Host
|
|
$path = $builder.Path.TrimEnd('/')
|
|
if ([string]::IsNullOrEmpty($path)) { $path = '/' }
|
|
$builder.Path = ($path.TrimEnd('/') + '/' + $normalizedKey)
|
|
}
|
|
return $builder.Uri
|
|
}
|
|
|
|
function Ensure-AWSS3Module {
|
|
try { $null = [Amazon.S3.AmazonS3Client]; return } catch {}
|
|
try { Import-Module -Name AWS.Tools.S3 -ErrorAction Stop; return } catch {}
|
|
try { Import-Module -Name AWSPowerShell.NetCore -ErrorAction Stop; return } catch {}
|
|
throw "AWS Tools for PowerShell not found. Install with: Install-Module AWS.Tools.S3 -Scope CurrentUser"
|
|
}
|
|
|
|
function New-S3Client {
|
|
param(
|
|
[string]$EndpointUrl,
|
|
[string]$Region,
|
|
[string]$AccessKeyId,
|
|
[string]$SecretAccessKey,
|
|
[bool]$ForcePathStyle = $true
|
|
)
|
|
Ensure-AWSS3Module
|
|
$creds = New-Object Amazon.Runtime.BasicAWSCredentials($AccessKeyId, $SecretAccessKey)
|
|
$cfg = New-Object Amazon.S3.AmazonS3Config
|
|
if ($EndpointUrl) { $cfg.ServiceURL = $EndpointUrl }
|
|
if ($Region) { try { $cfg.RegionEndpoint = [Amazon.RegionEndpoint]::GetBySystemName($Region) } catch {} }
|
|
$cfg.ForcePathStyle = [bool]$ForcePathStyle
|
|
return (New-Object Amazon.S3.AmazonS3Client($creds, $cfg))
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Active Directory credential and permission helpers
|
|
# (requires the ActiveDirectory module to be loaded before calling)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
function Get-ValidatedADCredential {
|
|
param (
|
|
[Parameter(Mandatory)][string]$DomainName,
|
|
[Parameter(Mandatory)][string]$Server,
|
|
[int]$MaxAttempts = 3
|
|
)
|
|
|
|
for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) {
|
|
$credential = Get-Credential -Message "Enter AD credentials with replication rights for $DomainName (attempt $attempt/$MaxAttempts)"
|
|
if ($null -eq $credential) {
|
|
throw "Credential prompt was cancelled."
|
|
}
|
|
|
|
try {
|
|
Get-ADDomain -Server $Server -Credential $credential -ErrorAction Stop | Out-Null
|
|
Write-Verbose ("Credential pre-check succeeded for '{0}' against '{1}'." -f $credential.UserName, $Server)
|
|
return $credential
|
|
} catch {
|
|
$message = $_.Exception.Message
|
|
if ($message -match 'rejected the client credentials|unknown user name|bad password|logon failure') {
|
|
Write-Warning ("Credentials were rejected for '{0}' (attempt {1}/{2})." -f $credential.UserName, $attempt, $MaxAttempts)
|
|
if ($attempt -lt $MaxAttempts) { continue }
|
|
throw "Credentials were rejected by domain controller '$Server' after $MaxAttempts attempts."
|
|
}
|
|
throw "Credential pre-check failed against '$Server': $message"
|
|
}
|
|
}
|
|
}
|
|
|
|
function Test-ReplicationPermissions {
|
|
param(
|
|
[Parameter(Mandatory)][string]$DomainDN,
|
|
[Parameter(Mandatory)][string]$Server,
|
|
[Parameter(Mandatory)][System.Management.Automation.PSCredential]$Credential
|
|
)
|
|
|
|
$requiredRights = [ordered]@{
|
|
'Replicating Directory Changes' = [guid]'1131f6aa-9c07-11d1-f79f-00c04fc2dcd2'
|
|
'Replicating Directory Changes All' = [guid]'1131f6ab-9c07-11d1-f79f-00c04fc2dcd2'
|
|
'Replicating Directory Changes In Filtered Set' = [guid]'89e95b76-444d-4c62-991a-0facbeda640c'
|
|
}
|
|
|
|
$callerSids = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
|
|
try {
|
|
$samName = $Credential.UserName -replace '^.*\\', ''
|
|
$adUser = Get-ADUser -Identity $samName -Server $Server -Credential $Credential `
|
|
-Properties SID, DistinguishedName -ErrorAction Stop
|
|
[void]$callerSids.Add($adUser.SID.Value)
|
|
|
|
# tokenGroups is a constructed attribute containing all SIDs in the user's token,
|
|
# including nested group memberships — more reliable than walking MemberOf recursively
|
|
$userDe = New-Object System.DirectoryServices.DirectoryEntry(
|
|
"LDAP://$Server/$($adUser.DistinguishedName)",
|
|
$Credential.UserName,
|
|
$Credential.GetNetworkCredential().Password
|
|
)
|
|
$userDe.RefreshCache(@('tokenGroups'))
|
|
foreach ($sidBytes in $userDe.Properties['tokenGroups']) {
|
|
$sid = New-Object System.Security.Principal.SecurityIdentifier($sidBytes, 0)
|
|
[void]$callerSids.Add($sid.Value)
|
|
}
|
|
} catch {
|
|
Write-Warning ("Could not resolve account SIDs for replication permission pre-check: {0}. Skipping." -f $_.Exception.Message)
|
|
return
|
|
}
|
|
|
|
$acl = $null
|
|
try {
|
|
$de = New-Object System.DirectoryServices.DirectoryEntry(
|
|
"LDAP://$Server/$DomainDN",
|
|
$Credential.UserName,
|
|
$Credential.GetNetworkCredential().Password
|
|
)
|
|
$acl = $de.ObjectSecurity.GetAccessRules(
|
|
$true, $true, [System.Security.Principal.SecurityIdentifier])
|
|
} catch {
|
|
Write-Warning ("Could not read domain object ACL for replication permission pre-check: {0}. Skipping." -f $_.Exception.Message)
|
|
return
|
|
}
|
|
|
|
$missing = @()
|
|
foreach ($rightName in $requiredRights.Keys) {
|
|
$guid = $requiredRights[$rightName]
|
|
$granted = $false
|
|
foreach ($ace in $acl) {
|
|
if ($ace.AccessControlType -ne [System.Security.AccessControl.AccessControlType]::Allow) { continue }
|
|
if (-not ($ace.ActiveDirectoryRights -band [System.DirectoryServices.ActiveDirectoryRights]::ExtendedRight)) { continue }
|
|
if ($ace.ObjectType -ne $guid) { continue }
|
|
if ($callerSids.Contains($ace.IdentityReference.Value)) { $granted = $true; break }
|
|
}
|
|
if (-not $granted) { $missing += $rightName }
|
|
}
|
|
|
|
if ($missing.Count -gt 0) {
|
|
throw ("Account '{0}' is missing the following replication permissions on '{1}':`n - {2}`n`nGrant these extended rights on the domain object to allow DCSync-based hash retrieval." -f `
|
|
$Credential.UserName, $DomainDN, ($missing -join "`n - "))
|
|
}
|
|
|
|
Write-Host ("[+] Replication permissions verified for '{0}'." -f $Credential.UserName)
|
|
}
|