Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1d98b908c6 | |||
| 906bb52638 | |||
| af945f529e | |||
| 03aa72f999 | |||
| 10cbf0285d | |||
| fc91f0d6b0 | |||
| 6b2ae6c8b5 | |||
| 37d1a8d971 | |||
| 0175864e72 | |||
| 9496063b97 | |||
| 27a682a968 | |||
| 255cfe0a17 | |||
| 09c30f97e9 | |||
| 5127c2d096 |
@@ -0,0 +1,161 @@
|
|||||||
|
##################################################
|
||||||
|
## ____ ___ ____ _____ _ _ _____ _____ ##
|
||||||
|
## / ___/ _ \| _ \| ____| | \ | | ____|_ _| ##
|
||||||
|
## | | | | | | |_) | _| | \| | _| | | ##
|
||||||
|
## | |__| |_| | _ <| |___ _| |\ | |___ | | ##
|
||||||
|
## \____\__\_\_| \_\_____(_)_| \_|_____| |_| ##
|
||||||
|
## Move fast and fix things. ##
|
||||||
|
##################################################
|
||||||
|
## Project: Elysium ##
|
||||||
|
## File: Bump-Version.ps1 ##
|
||||||
|
## Version: 2.4.4 ##
|
||||||
|
## Support: support@cqre.net ##
|
||||||
|
##################################################
|
||||||
|
|
||||||
|
#Requires -Version 5.1
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
Set-StrictMode -Version Latest
|
||||||
|
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Bumps the unified Elysium version across all project files.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Updates the centralized $ElysiumVersion variable, ASCII headers in all
|
||||||
|
operational scripts, the settings template, and runtime references
|
||||||
|
(User-Agent, usage beacon, etc.). Optionally stubs a new CHANGELOG entry.
|
||||||
|
|
||||||
|
.PARAMETER NewVersion
|
||||||
|
The new version string to apply (e.g. 2.2.2).
|
||||||
|
|
||||||
|
.PARAMETER SkipChangelog
|
||||||
|
Do not print a CHANGELOG entry stub.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\Bump-Version.ps1 -NewVersion 2.2.2
|
||||||
|
#>
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$NewVersion,
|
||||||
|
|
||||||
|
[switch]$SkipChangelog
|
||||||
|
)
|
||||||
|
|
||||||
|
$scriptRoot = $PSScriptRoot
|
||||||
|
if (-not $scriptRoot) { $scriptRoot = (Get-Location).Path }
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Validate input
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
if ($NewVersion -notmatch '^\d+\.\d+\.\d+$') {
|
||||||
|
throw "Version must be in semantic format X.Y.Z (e.g. 2.2.2). Got: '$NewVersion'"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Determine current version from Elysium.Common.ps1
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
$commonPath = Join-Path -Path $scriptRoot -ChildPath 'Elysium.Common.ps1'
|
||||||
|
if (-not (Test-Path -LiteralPath $commonPath)) {
|
||||||
|
throw "Elysium.Common.ps1 not found at $commonPath"
|
||||||
|
}
|
||||||
|
|
||||||
|
$commonContent = Get-Content -LiteralPath $commonPath -Raw
|
||||||
|
$currentVersionMatch = [regex]::Match($commonContent, "\`$script:ElysiumVersion\s*=\s*'([^']+)'")
|
||||||
|
if (-not $currentVersionMatch.Success) {
|
||||||
|
throw "Could not determine current version from Elysium.Common.ps1"
|
||||||
|
}
|
||||||
|
$oldVersion = $currentVersionMatch.Groups[1].Value
|
||||||
|
|
||||||
|
if ($oldVersion -eq $NewVersion) {
|
||||||
|
Write-Warning "Current version is already $NewVersion. Nothing to do."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Bumping Elysium from $oldVersion -> $NewVersion"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helper: replace in file
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
function Edit-FileVersion {
|
||||||
|
param(
|
||||||
|
[string]$Path,
|
||||||
|
[string]$Old,
|
||||||
|
[string]$New
|
||||||
|
)
|
||||||
|
$content = Get-Content -LiteralPath $Path -Raw
|
||||||
|
$newContent = $content.Replace($Old, $New)
|
||||||
|
if ($newContent -eq $content) {
|
||||||
|
Write-Verbose " No changes in $(Split-Path -Leaf $Path)"
|
||||||
|
} else {
|
||||||
|
Set-Content -LiteralPath $Path -Value $newContent -NoNewline -Encoding UTF8
|
||||||
|
Write-Host " Updated $(Split-Path -Leaf $Path)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 1. Central version variable
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
Write-Host "`n[1/4] Updating centralized version variable..."
|
||||||
|
Edit-FileVersion -Path $commonPath -Old "`$script:ElysiumVersion = '$oldVersion'" -New "`$script:ElysiumVersion = '$NewVersion'"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 2. ASCII headers in scripts and templates
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
Write-Host "`n[2/4] Updating script headers..."
|
||||||
|
$headerTargets = Get-ChildItem -Path $scriptRoot -File | Where-Object {
|
||||||
|
$_.Extension -in @('.ps1', '.py') -or $_.Name -eq 'ElysiumSettings.txt.sample'
|
||||||
|
}
|
||||||
|
foreach ($file in $headerTargets) {
|
||||||
|
$content = Get-Content -LiteralPath $file.FullName -Raw
|
||||||
|
# The header pattern: ## Version: X.Y.Z ##
|
||||||
|
$pattern = "## Version:\s+$([regex]::Escape($oldVersion))\s+##"
|
||||||
|
$replacement = "## Version: $NewVersion ##"
|
||||||
|
$newContent = [regex]::Replace($content, $pattern, $replacement)
|
||||||
|
if ($newContent -ne $content) {
|
||||||
|
Set-Content -LiteralPath $file.FullName -Value $newContent -NoNewline -Encoding UTF8
|
||||||
|
Write-Host " Updated $($file.Name)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 3. Runtime references (safety net)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
Write-Host "`n[3/4] Updating runtime version references..."
|
||||||
|
$runtimeTargets = Get-ChildItem -Path $scriptRoot -Filter '*.ps1' -File
|
||||||
|
foreach ($file in $runtimeTargets) {
|
||||||
|
$content = Get-Content -LiteralPath $file.FullName -Raw
|
||||||
|
$newContent = $content
|
||||||
|
# User-Agent patterns: 'Elysium/2.2.1 (+...)' or "Elysium/2.2.1 (+...)"
|
||||||
|
$newContent = [regex]::Replace($newContent,
|
||||||
|
"Elysium/$([regex]::Escape($oldVersion))",
|
||||||
|
"Elysium/$NewVersion")
|
||||||
|
# Literal string assignments: version = '2.2.1'
|
||||||
|
$newContent = [regex]::Replace($newContent,
|
||||||
|
"version\s*=\s*'$([regex]::Escape($oldVersion))'",
|
||||||
|
"version = '$NewVersion'")
|
||||||
|
if ($newContent -ne $content) {
|
||||||
|
Set-Content -LiteralPath $file.FullName -Value $newContent -NoNewline -Encoding UTF8
|
||||||
|
Write-Host " Updated runtime refs in $($file.Name)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 4. CHANGELOG stub
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
if (-not $SkipChangelog) {
|
||||||
|
Write-Host "`n[4/4] CHANGELOG entry stub (copy-paste ready):"
|
||||||
|
$today = (Get-Date).ToString('yyyy-MM-dd')
|
||||||
|
$stub = @"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [$NewVersion] - $today
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- (describe your change here)
|
||||||
|
"@
|
||||||
|
Write-Host $stub
|
||||||
|
Write-Host "`nAppend the above to CHANGELOG.md, then commit and tag."
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "`nDone. Review the changes with: git diff --stat"
|
||||||
+102
@@ -6,6 +6,108 @@ Starting with **v2.2.0**, Elysium uses a **unified project version**. All script
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [2.4.4] — 2026-06-15
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- `Test-ReplicationPermissions` now checks **both** the domain NC (`DC=…`) and the schema NC (`CN=Schema,CN=Configuration,DC=…`) for the required DCSync extended rights. DSInternals 7.0 changed schema fetching from LDAP to DRS (`GetNCChanges`), so the schema NC now requires its own ACL entry. Previously the pre-flight check passed (domain NC rights present) while `Get-ADReplAccount` immediately failed at `FetchSchema()` with "Replication access was denied".
|
||||||
|
- The `Replication access was denied` catch block in `Test-WeakADPasswords` now emits a structured, actionable error message that names the exact DNs to target and explains the DSInternals 7.0 schema NC change, replacing the previous generic "ensure this account has replication rights on the domain" message.
|
||||||
|
- Diagnostic dump (`dcsync-diag-*.txt`) now includes a `SchemaDN` field so the schema NC path is immediately visible when triaging a dump.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Least-privilege requirement updated: the DCSync service account now needs the three replication extended rights on **both** the domain NC *and* `CN=Configuration,DC=…` (which covers the schema NC via inheritance). See *Least privileges* in the README for delegation steps.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.4.3] — 2026-06-09
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Replaced the `DirectoryEntry` + `RefreshCache` tokenGroups retrieval in `Test-ReplicationPermissions` with `Get-ADUser -Properties tokenGroups`. The previous `DirectoryEntry` approach was broken by the v2.4.1 URI-escaping "fix" (`EscapeDataString` produces percent-encoded paths that ADSI `DirectoryEntry` cannot parse, causing "invalid dn syntax" errors).
|
||||||
|
- Removed `EscapeDataString` from the ACL-reading `DirectoryEntry` path in `Test-ReplicationPermissions` as well, since `DirectoryEntry` expects raw LDAP path syntax, not URI encoding.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.4.2] — 2026-06-09
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Replaced UTF-8 em-dashes (`\u2014`) in `Elysium.Common.ps1` and `Bump-Version.ps1` with ASCII hyphens. On Windows PowerShell without a UTF-8 BOM, the three-byte em-dash sequence was misinterpreted as containing a quote character, causing cascading parse errors (unexpected token, missing closing `)`/`}`/`catch`, etc.).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.4.1] — 2026-06-09
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- `Test-ReplicationPermissions` and `Test-DCClockSkew` now URI-escape Distinguished Names via `[System.Uri]::EscapeDataString` before embedding them in `DirectoryEntry` LDAP URLs. DNs containing `/`, `#`, or other reserved characters previously caused URL mis-parsing and constructor failures.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.4.0] — 2026-06-09
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **DC clock skew pre-flight check** (`Test-DCClockSkew` in `Elysium.Common.ps1`): compares the local machine clock against the target DC's `RootDSE.currentTime` before attempting DCSync. Warns if skew exceeds 300s (Kerberos hard limit) or 60s (approaching limit), and provides the `w32tm /resync /force` remediation command.
|
||||||
|
- **SDProp protection warning** in `Test-ReplicationPermissions`: detects `adminCount=1` on the service account and warns that SDProp runs every 60 minutes and may silently revert replication rights or group memberships.
|
||||||
|
- **Protected Users group warning** in `Test-ReplicationPermissions`: detects membership in the Protected Users group (RID 525) and warns that it restricts Kerberos delegation and RC4 authentication required by DSInternals for DRS replication.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- DSInternals auto-update flow now uses `Install-Module -Force -AllowClobber` instead of `Update-Module` to avoid a PowerShellGet bug where null `PublishedDate` metadata causes "cannot convert null to type system.datetime".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.3.0] — 2026-06-09
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- `Test-WeakADPasswords.ps1` now checks the installed DSInternals version at startup:
|
||||||
|
- **v6.2** (unsigned) is flagged with a warning explaining that unsigned native DLLs are blocked and replication will fail. Remediation: `Update-Module DSInternals`.
|
||||||
|
- **Below v7.0** triggers an interactive prompt offering to run `Update-Module DSInternals -Force` automatically. If accepted, the script updates the module and exits cleanly so the operator can re-run with the new version loaded.
|
||||||
|
- v7.0+ is required because it fixes intermittent CRC errors mid-replication and `Test-PasswordQuality` result truncation bugs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.2.5] — 2026-06-09
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- The DSInternals `Zone.Identifier` block error message (added in v2.2.4) now dynamically resolves the actual DSInternals module path via `Get-Module` instead of hardcoding `$env:ProgramFiles\WindowsPowerShell\DSInternals`. The `Unblock-File` command in the error now points to the correct installation directory.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.2.4] — 2026-06-09
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- `Test-ReplicationPermissions` (in `Elysium.Common.ps1`) now skips `InheritOnly` ACEs when evaluating replication rights. An ACE marked `InheritOnly` applies only to child objects, not the domain root itself, so it does not grant the required extended rights for DCSync on the domain object.
|
||||||
|
- `Import-CompatModule` (in `Test-WeakADPasswords.ps1`) now detects DSInternals being blocked by Windows `Zone.Identifier` (alternate data stream from internet download) and throws a clear, actionable error with the exact `Unblock-File` command to run. Previously this surfaced as an opaque non-FIPS warning.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.2.3] — 2026-06-09
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- `Test-ReplicationPermissions` (in `Elysium.Common.ps1`) now correctly recognizes `GenericAll` and blanket `ExtendedRight` (empty ObjectType) ACEs as satisfying replication permission requirements. Previously, only exact GUID-matched ExtendedRight ACEs were detected, causing false negatives when rights were granted via broader permissions.
|
||||||
|
- Improved error diagnostics: the missing-rights message now indicates whether an ACE for the specific right exists on the domain object but is not assigned to the caller, versus no ACE existing at all.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.2.2] — 2026-06-09
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- `Test-ReplicationPermissions` (in `Elysium.Common.ps1`) now resolves the caller's **effective token SIDs** via the `tokenGroups` constructed attribute instead of walking `MemberOf` directly. This correctly accounts for nested group memberships and avoids false-positive "missing permissions" errors when the account is entitled through nested groups.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.2.1] — 2026-06-09
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **DRY refactoring — shared helpers consolidated into `Elysium.Common.ps1`:**
|
||||||
|
- Moved `Read-KeyValueSettingsFile`, `Read-ElysiumSettings`, and `Get-SettingsValue` from `Prepare-KHDBStorage.ps1` and `Update-KHDB.ps1` into the common helper.
|
||||||
|
- Moved `Build-BlobUri` and Azure URI helpers from `Update-KHDB.ps1` into the common helper.
|
||||||
|
- Moved `Get-FunctionDefinitionText` from all scripts that duplicated it into the common helper.
|
||||||
|
- Moved `Get-ValidatedADCredential` and `Test-ReplicationPermissions` from `Test-WeakADPasswords.ps1` into the common helper.
|
||||||
|
- Moved all native S3 SigV4 helpers (`Ensure-AWSS3Module`, `New-S3Client`, `HmacSha256`, `GetSignatureKey`, `BuildAuthHeaders`, `BuildS3Uri`, etc.) from `Extract-NTHashes.ps1` into the common helper.
|
||||||
|
- `Test-WeakADPasswords.ps1` and `Extract-NTHashes.ps1` now import `Elysium.Common.ps1` (they previously did not), reducing duplication and ensuring consistent behavior.
|
||||||
|
- `Update-KHDB.ps1` and `Prepare-KHDBStorage.ps1` removed their local copies of helpers already available in the common module.
|
||||||
|
- Removed legacy `Settings.ps1` (superseded by `ElysiumSettings.txt`).
|
||||||
|
- Minor cleanup: removed stray placeholder comment in `Elysium.ps1`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [2.2.0] — 2026-06-09
|
## [2.2.0] — 2026-06-09
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
$script:ElysiumVersion = '2.4.4'
|
||||||
|
|
||||||
function Invoke-RestartWithExecutable {
|
function Invoke-RestartWithExecutable {
|
||||||
param(
|
param(
|
||||||
[string]$ExecutablePath,
|
[string]$ExecutablePath,
|
||||||
@@ -68,3 +70,404 @@ function Restart-WithWindowsPowerShellIfAvailable {
|
|||||||
Write-Host ("Windows PowerShell detected at '{0}'; relaunching script under powershell.exe..." -f $powershellPath)
|
Write-Host ("Windows PowerShell detected at '{0}'; relaunching script under powershell.exe..." -f $powershellPath)
|
||||||
Invoke-RestartWithExecutable -ExecutablePath $powershellPath -BoundParameters $BoundParameters -UnboundArguments $UnboundArguments
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
$allThreeRights = [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'
|
||||||
|
}
|
||||||
|
|
||||||
|
# DSInternals 7.0 fetches the AD schema via DRS (GetNCChanges) before replicating accounts.
|
||||||
|
# The schema NC has its own ACL - rights on the domain NC do not cover it.
|
||||||
|
# Older DSInternals read schema via LDAP (no special rights needed); v7.0 switched to DRS.
|
||||||
|
$schemaDN = "CN=Schema,CN=Configuration,$DomainDN"
|
||||||
|
|
||||||
|
$ncsToCheck = [ordered]@{
|
||||||
|
$DomainDN = $allThreeRights
|
||||||
|
$schemaDN = [ordered]@{
|
||||||
|
'Replicating Directory Changes' = [guid]'1131f6aa-9c07-11d1-f79f-00c04fc2dcd2'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$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, adminCount -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
|
||||||
|
$adUserWithTokenGroups = Get-ADUser -Identity $samName -Server $Server -Credential $Credential `
|
||||||
|
-Properties tokenGroups -ErrorAction Stop
|
||||||
|
foreach ($sidBytes in $adUserWithTokenGroups.tokenGroups) {
|
||||||
|
$sid = New-Object System.Security.Principal.SecurityIdentifier(@([byte[]]$sidBytes), 0)
|
||||||
|
[void]$callerSids.Add($sid.Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
# adminCount=1 means SDProp is managing this account; it runs every 60 min and can
|
||||||
|
# silently revert replication rights or group memberships granted to the account
|
||||||
|
if ($adUser.adminCount -eq 1) {
|
||||||
|
Write-Warning ("Account '{0}' has adminCount=1 (SDProp-protected). It is or was a member of a privileged group. SDProp runs every 60 minutes and may silently revert replication rights or group memberships on this account." -f $Credential.UserName)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Protected Users group (RID 525) blocks the Kerberos mechanisms DSInternals uses for DRS
|
||||||
|
$domainSidStr = $adUser.SID.Value.Substring(0, $adUser.SID.Value.LastIndexOf('-'))
|
||||||
|
$protectedUsersSid = "$domainSidStr-525"
|
||||||
|
if ($callerSids.Contains($protectedUsersSid)) {
|
||||||
|
Write-Warning ("Account '{0}' is a member of Protected Users. This group restricts Kerberos delegation and RC4 authentication that DSInternals requires for DRS replication - access will be denied regardless of assigned rights." -f $Credential.UserName)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Write-Warning ("Could not resolve account SIDs for replication permission pre-check: {0}. Skipping." -f $_.Exception.Message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
$allMissingLines = @()
|
||||||
|
|
||||||
|
foreach ($ncEntry in $ncsToCheck.GetEnumerator()) {
|
||||||
|
$ncDN = $ncEntry.Key
|
||||||
|
$rightsToCheck = $ncEntry.Value
|
||||||
|
|
||||||
|
$acl = $null
|
||||||
|
try {
|
||||||
|
$de = New-Object System.DirectoryServices.DirectoryEntry(
|
||||||
|
"LDAP://$Server/$ncDN",
|
||||||
|
$Credential.UserName,
|
||||||
|
$Credential.GetNetworkCredential().Password
|
||||||
|
)
|
||||||
|
$acl = $de.ObjectSecurity.GetAccessRules(
|
||||||
|
$true, $true, [System.Security.Principal.SecurityIdentifier])
|
||||||
|
} catch {
|
||||||
|
Write-Warning ("Could not read ACL on '$ncDN' for replication permission pre-check: {0}. Skipping." -f $_.Exception.Message)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($rightName in $rightsToCheck.Keys) {
|
||||||
|
$guid = $rightsToCheck[$rightName]
|
||||||
|
$granted = $false
|
||||||
|
$aceExistsForGuid = $false
|
||||||
|
foreach ($ace in $acl) {
|
||||||
|
if ($ace.AccessControlType -ne [System.Security.AccessControl.AccessControlType]::Allow) { continue }
|
||||||
|
# InheritOnly ACEs apply to child objects only - the NC root itself is not covered
|
||||||
|
if ([bool]($ace.PropagationFlags -band [System.Security.AccessControl.PropagationFlags]::InheritOnly)) { continue }
|
||||||
|
$rights = $ace.ActiveDirectoryRights
|
||||||
|
$hasExtended = [bool]($rights -band [System.DirectoryServices.ActiveDirectoryRights]::ExtendedRight)
|
||||||
|
$hasGenericAll = [bool]($rights -band [System.DirectoryServices.ActiveDirectoryRights]::GenericAll)
|
||||||
|
# Match: exact GUID, OR ExtendedRight with empty ObjectType (all extended rights), OR GenericAll
|
||||||
|
$isMatch = $hasGenericAll `
|
||||||
|
-or ($hasExtended -and $ace.ObjectType -eq [guid]::Empty) `
|
||||||
|
-or ($hasExtended -and $ace.ObjectType -eq $guid)
|
||||||
|
if (-not $isMatch) { continue }
|
||||||
|
if ($ace.ObjectType -eq $guid) { $aceExistsForGuid = $true }
|
||||||
|
if ($callerSids.Contains($ace.IdentityReference.Value)) { $granted = $true; break }
|
||||||
|
}
|
||||||
|
if (-not $granted) {
|
||||||
|
$hint = if ($aceExistsForGuid) {
|
||||||
|
' (ACE exists but not assigned to this account or any of its groups)'
|
||||||
|
} else {
|
||||||
|
' (no ACE found for this right on this object)'
|
||||||
|
}
|
||||||
|
$allMissingLines += "[on $ncDN] $rightName$hint"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($allMissingLines.Count -gt 0) {
|
||||||
|
$schemaNote = ''
|
||||||
|
if ($allMissingLines | Where-Object { $_ -match [regex]::Escape($schemaDN) }) {
|
||||||
|
$schemaNote = ("`n`nNOTE: DSInternals 7.0 fetches the AD schema via DRS before replicating accounts." +
|
||||||
|
" Grant 'Replicating Directory Changes' on CN=Configuration,$DomainDN" +
|
||||||
|
" (covers Schema NC via inheritance) in addition to the domain NC rights.")
|
||||||
|
}
|
||||||
|
throw ("Account '{0}' failed replication permission check:`n - {1}{2}" -f `
|
||||||
|
$Credential.UserName, ($allMissingLines -join "`n - "), $schemaNote)
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ("[+] Replication permissions verified for '{0}' on domain NC and schema NC." -f $Credential.UserName)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Test-DCClockSkew {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)][string]$Server,
|
||||||
|
[Parameter(Mandatory)][System.Management.Automation.PSCredential]$Credential
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
$rootDse = New-Object System.DirectoryServices.DirectoryEntry(
|
||||||
|
"LDAP://$Server/RootDSE",
|
||||||
|
$Credential.UserName,
|
||||||
|
$Credential.GetNetworkCredential().Password
|
||||||
|
)
|
||||||
|
$dcTimeStr = $rootDse.Properties['currentTime'][0]
|
||||||
|
$dcTime = [datetime]::ParseExact(
|
||||||
|
$dcTimeStr, 'yyyyMMddHHmmss.0Z',
|
||||||
|
[System.Globalization.CultureInfo]::InvariantCulture,
|
||||||
|
[System.Globalization.DateTimeStyles]::AssumeUniversal).ToUniversalTime()
|
||||||
|
$skewSeconds = [Math]::Abs(([datetime]::UtcNow - $dcTime).TotalSeconds)
|
||||||
|
if ($skewSeconds -gt 300) {
|
||||||
|
Write-Warning ("Clock skew of {0:N0}s with '{1}' exceeds Kerberos limit of 300s - authentication will fail. Sync the clock: w32tm /resync /force" -f $skewSeconds, $Server)
|
||||||
|
} elseif ($skewSeconds -gt 60) {
|
||||||
|
Write-Warning ("Clock skew of {0:N0}s detected with '{1}'. Kerberos allows up to 300s - approaching the limit." -f $skewSeconds, $Server)
|
||||||
|
} else {
|
||||||
|
Write-Host ("[+] Clock skew with '{0}': {1:N0}s (OK)." -f $Server, $skewSeconds)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Write-Warning ("Could not check clock skew against '{0}': {1}" -f $Server, $_.Exception.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+1
-3
@@ -7,7 +7,7 @@
|
|||||||
##################################################
|
##################################################
|
||||||
## Project: Elysium ##
|
## Project: Elysium ##
|
||||||
## File: Elysium.ps1 ##
|
## File: Elysium.ps1 ##
|
||||||
## Version: 2.2.0 ##
|
## Version: 2.4.4 ##
|
||||||
## Support: support@cqre.net ##
|
## Support: support@cqre.net ##
|
||||||
##################################################
|
##################################################
|
||||||
|
|
||||||
@@ -52,8 +52,6 @@ if ([string]::IsNullOrEmpty($passphrase)) {
|
|||||||
Write-Host "Passphrase found in environment variables."
|
Write-Host "Passphrase found in environment variables."
|
||||||
}
|
}
|
||||||
|
|
||||||
# Continue with the rest of your script...
|
|
||||||
|
|
||||||
function Start-OrchestratorTranscript {
|
function Start-OrchestratorTranscript {
|
||||||
param([string]$BasePath)
|
param([string]$BasePath)
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
##################################################
|
##################################################
|
||||||
## Project: Elysium ##
|
## Project: Elysium ##
|
||||||
## File: ElysiumSettings.txt ##
|
## File: ElysiumSettings.txt ##
|
||||||
## Version: 2.2.0 ##
|
## Version: 2.4.4 ##
|
||||||
## Support: support@cqre.net ##
|
## Support: support@cqre.net ##
|
||||||
##################################################
|
##################################################
|
||||||
|
|
||||||
|
|||||||
+134
-236
@@ -6,8 +6,8 @@
|
|||||||
## \____\__\_\_| \_\_____(_)_| \_|_____| |_| ##
|
## \____\__\_\_| \_\_____(_)_| \_|_____| |_| ##
|
||||||
##################################################
|
##################################################
|
||||||
## Project: Elysium ##
|
## Project: Elysium ##
|
||||||
## File: Extract-NTLMHashes.ps1 ##
|
## File: Extract-NTHashes.ps1 ##
|
||||||
## Version: 2.2.0 ##
|
## Version: 2.4.4 ##
|
||||||
## Support: support@cqre.net ##
|
## Support: support@cqre.net ##
|
||||||
##################################################
|
##################################################
|
||||||
|
|
||||||
@@ -25,6 +25,11 @@ Set-StrictMode -Version Latest
|
|||||||
|
|
||||||
$scriptRoot = $PSScriptRoot
|
$scriptRoot = $PSScriptRoot
|
||||||
|
|
||||||
|
[string]$commonHelper = Join-Path -Path $PSScriptRoot -ChildPath 'Elysium.Common.ps1'
|
||||||
|
if (-not (Test-Path -LiteralPath $commonHelper)) { throw "Common helper not found at $commonHelper" }
|
||||||
|
. $commonHelper
|
||||||
|
Restart-WithWindowsPowerShellIfAvailable -BoundParameters $PSBoundParameters -UnboundArguments $MyInvocation.UnboundArguments
|
||||||
|
|
||||||
function Start-ExtractTranscript {
|
function Start-ExtractTranscript {
|
||||||
param([string]$BasePath)
|
param([string]$BasePath)
|
||||||
try {
|
try {
|
||||||
@@ -40,167 +45,18 @@ function Start-ExtractTranscript {
|
|||||||
|
|
||||||
function Stop-ExtractTranscript { try { Stop-Transcript | Out-Null } catch {} }
|
function Stop-ExtractTranscript { try { Stop-Transcript | Out-Null } catch {} }
|
||||||
|
|
||||||
Start-ExtractTranscript -BasePath $scriptRoot
|
|
||||||
try {
|
|
||||||
# Import settings
|
|
||||||
Write-Host "Loading settings..."
|
|
||||||
$ElysiumSettings = @{}
|
|
||||||
$settingsPath = Join-Path -Path $scriptRoot -ChildPath "ElysiumSettings.txt"
|
|
||||||
|
|
||||||
if (-not (Test-Path $settingsPath)) {
|
|
||||||
Write-Error "Settings file not found at $settingsPath"
|
|
||||||
exit
|
|
||||||
}
|
|
||||||
|
|
||||||
Get-Content $settingsPath | ForEach-Object {
|
|
||||||
if (-not [string]::IsNullOrWhiteSpace($_) -and -not $_.StartsWith("#")) {
|
|
||||||
$keyValue = $_ -split '=', 2
|
|
||||||
if ($keyValue.Count -eq 2) {
|
|
||||||
$ElysiumSettings[$keyValue[0].Trim()] = $keyValue[1].Trim()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function Normalize-ReportPath([string]$p) {
|
function Normalize-ReportPath([string]$p) {
|
||||||
if ([string]::IsNullOrWhiteSpace($p)) { return (Join-Path -Path $scriptRoot -ChildPath 'Reports') }
|
if ([string]::IsNullOrWhiteSpace($p)) { return (Join-Path -Path $scriptRoot -ChildPath 'Reports') }
|
||||||
if ([System.IO.Path]::IsPathRooted($p)) { return $p }
|
if ([System.IO.Path]::IsPathRooted($p)) { return $p }
|
||||||
return (Join-Path -Path $scriptRoot -ChildPath $p)
|
return (Join-Path -Path $scriptRoot -ChildPath $p)
|
||||||
}
|
}
|
||||||
|
|
||||||
# Storage provider selection (Azure by default)
|
|
||||||
$storageProvider = $ElysiumSettings['StorageProvider']
|
|
||||||
if ([string]::IsNullOrWhiteSpace($storageProvider)) { $storageProvider = 'Azure' }
|
|
||||||
|
|
||||||
# Azure settings
|
|
||||||
$storageAccountName = $ElysiumSettings['storageAccountName']
|
|
||||||
$containerName = $ElysiumSettings['containerName']
|
|
||||||
$sasToken = $ElysiumSettings['sasToken']
|
|
||||||
|
|
||||||
# S3-compatible settings
|
|
||||||
$s3EndpointUrl = $ElysiumSettings['s3EndpointUrl']
|
|
||||||
$s3Region = $ElysiumSettings['s3Region']
|
|
||||||
$s3BucketName = $ElysiumSettings['s3BucketName']
|
|
||||||
$s3AccessKeyId = $ElysiumSettings['s3AccessKeyId']
|
|
||||||
$s3SecretAccessKey = $ElysiumSettings['s3SecretAccessKey']
|
|
||||||
$s3ForcePathStyle = $ElysiumSettings['s3ForcePathStyle']
|
|
||||||
$s3UseAwsTools = $ElysiumSettings['s3UseAwsTools']
|
|
||||||
if ([string]::IsNullOrWhiteSpace($s3Region)) { $s3Region = 'us-east-1' }
|
|
||||||
try { $s3ForcePathStyle = [System.Convert]::ToBoolean($s3ForcePathStyle) } catch { $s3ForcePathStyle = $true }
|
|
||||||
try { $s3UseAwsTools = [System.Convert]::ToBoolean($s3UseAwsTools) } catch { $s3UseAwsTools = $false }
|
|
||||||
|
|
||||||
function Ensure-AWSS3Module {
|
|
||||||
# Ensure AWS SDK types are available via AWS Tools for PowerShell
|
|
||||||
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))
|
|
||||||
}
|
|
||||||
|
|
||||||
# Native S3 SigV4 (no AWS Tools) helpers
|
|
||||||
function Get-Bytes([string]$s) { return [System.Text.Encoding]::UTF8.GetBytes($s) }
|
|
||||||
function Get-HashHex([byte[]]$bytes) {
|
|
||||||
$sha = [System.Security.Cryptography.SHA256]::Create()
|
|
||||||
try {
|
|
||||||
if ($null -eq $bytes) { $bytes = [byte[]]@() }
|
|
||||||
$ms = [System.IO.MemoryStream]::new($bytes)
|
|
||||||
try {
|
|
||||||
$hashBytes = $sha.ComputeHash($ms)
|
|
||||||
return ([BitConverter]::ToString($hashBytes)).Replace('-', '').ToLowerInvariant()
|
|
||||||
} finally { $ms.Dispose() }
|
|
||||||
} finally { $sha.Dispose() }
|
|
||||||
}
|
|
||||||
function Get-FileSha256Hex([string]$path) {
|
function Get-FileSha256Hex([string]$path) {
|
||||||
$sha = [System.Security.Cryptography.SHA256]::Create()
|
$sha = [System.Security.Cryptography.SHA256]::Create()
|
||||||
$fs = [System.IO.File]::OpenRead($path)
|
$fs = [System.IO.File]::OpenRead($path)
|
||||||
try { return ([BitConverter]::ToString($sha.ComputeHash($fs))).Replace('-', '').ToLowerInvariant() } finally { $fs.Close(); $sha.Dispose() }
|
try { return ([BitConverter]::ToString($sha.ComputeHash($fs))).Replace('-', '').ToLowerInvariant() } finally { $fs.Close(); $sha.Dispose() }
|
||||||
}
|
}
|
||||||
function HmacSha256([byte[]]$key, [string]$data) {
|
|
||||||
$h = [System.Security.Cryptography.HMACSHA256]::new($key)
|
|
||||||
try {
|
|
||||||
$dataBytes = Get-Bytes $data
|
|
||||||
$ms = [System.IO.MemoryStream]::new($dataBytes)
|
|
||||||
try { return $h.ComputeHash($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
|
|
||||||
return (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 ($seg in $segments) { $encoded += (UriEncode $seg $false) }
|
|
||||||
$path = ($encoded -join '/')
|
|
||||||
if (-not $path.StartsWith('/')) { $path = '/' + $path }
|
|
||||||
return $path
|
|
||||||
}
|
|
||||||
function ToHex([byte[]]$bytes) { return ([BitConverter]::ToString($bytes)).Replace('-', '').ToLowerInvariant() }
|
|
||||||
function BuildAuthHeaders($method, [System.Uri]$uri, [string]$region, [string]$accessKey, [string]$secretKey, [string]$payloadHash) {
|
|
||||||
$algorithm = 'AWS4-HMAC-SHA256'
|
|
||||||
$amzdate = (Get-Date).ToUniversalTime().ToString('yyyyMMddTHHmmssZ')
|
|
||||||
$datestamp = (Get-Date).ToUniversalTime().ToString('yyyyMMdd')
|
|
||||||
$hostHeader = $uri.Host
|
|
||||||
if (-not $uri.IsDefaultPort) { $hostHeader = "{0}:{1}" -f $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"
|
|
||||||
return @{ '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
|
|
||||||
$ub = [System.UriBuilder]::new($base)
|
|
||||||
if ($forcePathStyle) {
|
|
||||||
$p = ($ub.Path.TrimEnd('/'))
|
|
||||||
if ([string]::IsNullOrEmpty($p)) { $p = '/' }
|
|
||||||
$ub.Path = ($p.TrimEnd('/') + '/' + $bucket + '/' + $key)
|
|
||||||
} else {
|
|
||||||
$ub.Host = "$bucket." + $ub.Host
|
|
||||||
$p = $ub.Path.TrimEnd('/')
|
|
||||||
if ([string]::IsNullOrEmpty($p)) { $p = '/' }
|
|
||||||
$ub.Path = ($p.TrimEnd('/') + '/' + $key)
|
|
||||||
}
|
|
||||||
return $ub.Uri
|
|
||||||
}
|
|
||||||
function Invoke-S3PutFile([string]$endpointUrl, [string]$bucket, [string]$key, [string]$filePath, [string]$region, [string]$ak, [string]$sk, [bool]$forcePathStyle) {
|
function Invoke-S3PutFile([string]$endpointUrl, [string]$bucket, [string]$key, [string]$filePath, [string]$region, [string]$ak, [string]$sk, [bool]$forcePathStyle) {
|
||||||
$uri = BuildS3Uri -endpointUrl $endpointUrl -bucket $bucket -key $key -forcePathStyle $forcePathStyle
|
$uri = BuildS3Uri -endpointUrl $endpointUrl -bucket $bucket -key $key -forcePathStyle $forcePathStyle
|
||||||
$payloadHash = Get-FileSha256Hex -path $filePath
|
$payloadHash = Get-FileSha256Hex -path $filePath
|
||||||
@@ -218,6 +74,7 @@ function Invoke-S3PutFile([string]$endpointUrl, [string]$bucket, [string]$key, [
|
|||||||
if (-not $resp.IsSuccessStatusCode) { throw "S3 PUT failed: $([int]$resp.StatusCode) $($resp.ReasonPhrase)" }
|
if (-not $resp.IsSuccessStatusCode) { throw "S3 PUT failed: $([int]$resp.StatusCode) $($resp.ReasonPhrase)" }
|
||||||
} finally { if ($req) { $req.Dispose() }; if ($stream) { $stream.Close(); $stream.Dispose() }; $client.Dispose() }
|
} finally { if ($req) { $req.Dispose() }; if ($stream) { $stream.Close(); $stream.Dispose() }; $client.Dispose() }
|
||||||
}
|
}
|
||||||
|
|
||||||
function Invoke-S3GetToFile([string]$endpointUrl, [string]$bucket, [string]$key, [string]$targetPath, [string]$region, [string]$ak, [string]$sk, [bool]$forcePathStyle) {
|
function Invoke-S3GetToFile([string]$endpointUrl, [string]$bucket, [string]$key, [string]$targetPath, [string]$region, [string]$ak, [string]$sk, [bool]$forcePathStyle) {
|
||||||
$uri = BuildS3Uri -endpointUrl $endpointUrl -bucket $bucket -key $key -forcePathStyle $forcePathStyle
|
$uri = BuildS3Uri -endpointUrl $endpointUrl -bucket $bucket -key $key -forcePathStyle $forcePathStyle
|
||||||
$payloadHash = (Get-HashHex (Get-Bytes ''))
|
$payloadHash = (Get-HashHex (Get-Bytes ''))
|
||||||
@@ -236,13 +93,6 @@ function Invoke-S3GetToFile([string]$endpointUrl, [string]$bucket, [string]$key,
|
|||||||
} finally { if ($req) { $req.Dispose() }; $client.Dispose() }
|
} finally { if ($req) { $req.Dispose() }; $client.Dispose() }
|
||||||
}
|
}
|
||||||
|
|
||||||
# Retrieve the passphrase from a user environment variable
|
|
||||||
$passphrase = [System.Environment]::GetEnvironmentVariable("ELYSIUM_PASSPHRASE", [System.EnvironmentVariableTarget]::User)
|
|
||||||
if ([string]::IsNullOrWhiteSpace($passphrase)) { Write-Error 'Passphrase not found in ELYSIUM_PASSPHRASE environment variable.'; exit }
|
|
||||||
|
|
||||||
# Timestamp
|
|
||||||
$timestamp = Get-Date -Format "yyyyMMdd-HHmmss"
|
|
||||||
|
|
||||||
function Protect-FileWithAES {
|
function Protect-FileWithAES {
|
||||||
param (
|
param (
|
||||||
[Parameter(Mandatory = $true)]
|
[Parameter(Mandatory = $true)]
|
||||||
@@ -255,7 +105,6 @@ function Protect-FileWithAES {
|
|||||||
[string]$Passphrase
|
[string]$Passphrase
|
||||||
)
|
)
|
||||||
|
|
||||||
# Derive key with PBKDF2 (HMACSHA256) + random salt
|
|
||||||
$rng = [System.Security.Cryptography.RandomNumberGenerator]::Create()
|
$rng = [System.Security.Cryptography.RandomNumberGenerator]::Create()
|
||||||
$salt = New-Object byte[] 16
|
$salt = New-Object byte[] 16
|
||||||
$rng.GetBytes($salt)
|
$rng.GetBytes($salt)
|
||||||
@@ -277,7 +126,6 @@ function Protect-FileWithAES {
|
|||||||
$outFileStream = [System.IO.File]::Create($OutputFile)
|
$outFileStream = [System.IO.File]::Create($OutputFile)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
# File header: magic 'ELY1' (4 bytes), salt (16 bytes), IV (16 bytes)
|
|
||||||
$magic = [System.Text.Encoding]::ASCII.GetBytes('ELY1')
|
$magic = [System.Text.Encoding]::ASCII.GetBytes('ELY1')
|
||||||
$outFileStream.Write($magic, 0, $magic.Length)
|
$outFileStream.Write($magic, 0, $magic.Length)
|
||||||
$outFileStream.Write($salt, 0, $salt.Length)
|
$outFileStream.Write($salt, 0, $salt.Length)
|
||||||
@@ -299,6 +147,7 @@ function Protect-FileWithAES {
|
|||||||
|
|
||||||
Write-Host "File has been encrypted (PBKDF2+AES-256-CBC): $OutputFile"
|
Write-Host "File has been encrypted (PBKDF2+AES-256-CBC): $OutputFile"
|
||||||
}
|
}
|
||||||
|
|
||||||
function Get-FileChecksum {
|
function Get-FileChecksum {
|
||||||
param (
|
param (
|
||||||
[string]$Path,
|
[string]$Path,
|
||||||
@@ -309,79 +158,128 @@ function Get-FileChecksum {
|
|||||||
try {
|
try {
|
||||||
$hashBytes = $hasher.ComputeHash($stream)
|
$hashBytes = $hasher.ComputeHash($stream)
|
||||||
return [BitConverter]::ToString($hashBytes) -replace '-', ''
|
return [BitConverter]::ToString($hashBytes) -replace '-', ''
|
||||||
}
|
} finally {
|
||||||
finally {
|
|
||||||
$stream.Close()
|
$stream.Close()
|
||||||
$hasher.Dispose()
|
$hasher.Dispose()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# Extract NTLM hashes
|
Start-ExtractTranscript -BasePath $scriptRoot
|
||||||
$reportBase = Normalize-ReportPath -p $ElysiumSettings['ReportPathBase']
|
try {
|
||||||
if (-not (Test-Path $reportBase)) { New-Item -Path $reportBase -ItemType Directory -Force | Out-Null }
|
Write-Host "Loading settings..."
|
||||||
|
$ElysiumSettings = Read-ElysiumSettings -ScriptRoot $scriptRoot
|
||||||
|
|
||||||
# Build domain details from settings (ordered to keep numeric index order)
|
# Storage provider selection (Azure by default)
|
||||||
$DomainDetails = [ordered]@{}
|
$storageProvider = $ElysiumSettings['StorageProvider']
|
||||||
for ($i = 1; $ElysiumSettings.ContainsKey("Domain${i}Name"); $i++) {
|
if ([string]::IsNullOrWhiteSpace($storageProvider)) { $storageProvider = 'Azure' }
|
||||||
|
|
||||||
|
# Azure settings
|
||||||
|
$storageAccountName = $ElysiumSettings['storageAccountName']
|
||||||
|
$containerName = $ElysiumSettings['containerName']
|
||||||
|
$sasToken = $ElysiumSettings['sasToken']
|
||||||
|
|
||||||
|
# S3-compatible settings
|
||||||
|
$s3EndpointUrl = $ElysiumSettings['s3EndpointUrl']
|
||||||
|
$s3Region = $ElysiumSettings['s3Region']
|
||||||
|
$s3BucketName = $ElysiumSettings['s3BucketName']
|
||||||
|
$s3AccessKeyId = $ElysiumSettings['s3AccessKeyId']
|
||||||
|
$s3SecretAccessKey = $ElysiumSettings['s3SecretAccessKey']
|
||||||
|
$s3ForcePathStyle = $ElysiumSettings['s3ForcePathStyle']
|
||||||
|
$s3UseAwsTools = $ElysiumSettings['s3UseAwsTools']
|
||||||
|
if ([string]::IsNullOrWhiteSpace($s3Region)) { $s3Region = 'us-east-1' }
|
||||||
|
try { $s3ForcePathStyle = [System.Convert]::ToBoolean($s3ForcePathStyle) } catch { $s3ForcePathStyle = $true }
|
||||||
|
try { $s3UseAwsTools = [System.Convert]::ToBoolean($s3UseAwsTools) } catch { $s3UseAwsTools = $false }
|
||||||
|
|
||||||
|
# Retrieve the passphrase from a user environment variable
|
||||||
|
$passphrase = [System.Environment]::GetEnvironmentVariable("ELYSIUM_PASSPHRASE", [System.EnvironmentVariableTarget]::User)
|
||||||
|
if ([string]::IsNullOrWhiteSpace($passphrase)) { throw 'Passphrase not found in ELYSIUM_PASSPHRASE environment variable.' }
|
||||||
|
|
||||||
|
$timestamp = Get-Date -Format "yyyyMMdd-HHmmss"
|
||||||
|
|
||||||
|
$reportBase = Normalize-ReportPath -p $ElysiumSettings['ReportPathBase']
|
||||||
|
if (-not (Test-Path $reportBase)) { New-Item -Path $reportBase -ItemType Directory -Force | Out-Null }
|
||||||
|
|
||||||
|
# Build domain details from settings (ordered to keep numeric index order)
|
||||||
|
$DomainDetails = [ordered]@{}
|
||||||
|
for ($i = 1; $ElysiumSettings.ContainsKey("Domain${i}Name"); $i++) {
|
||||||
$DomainDetails["$i"] = @{
|
$DomainDetails["$i"] = @{
|
||||||
Name = $ElysiumSettings["Domain${i}Name"]
|
Name = $ElysiumSettings["Domain${i}Name"]
|
||||||
DC = $ElysiumSettings["Domain${i}DC"]
|
DC = $ElysiumSettings["Domain${i}DC"]
|
||||||
DA = $ElysiumSettings["Domain${i}DA"]
|
DA = $ElysiumSettings["Domain${i}DA"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# User selects a domain
|
# User selects a domain
|
||||||
Write-Host "Select a domain to extract NTLM hashes:"
|
Write-Host "Select a domain to extract NTLM hashes:"
|
||||||
$DomainDetails.GetEnumerator() | Sort-Object { [int]$_.Key } | ForEach-Object { Write-Host "$($_.Key): $($_.Value.Name)" }
|
$DomainDetails.GetEnumerator() | Sort-Object { [int]$_.Key } | ForEach-Object { Write-Host "$($_.Key): $($_.Value.Name)" }
|
||||||
$selection = Read-Host "Enter the number of the domain"
|
$selection = Read-Host "Enter the number of the domain"
|
||||||
$selectedDomain = $DomainDetails[$selection]
|
$selectedDomain = $DomainDetails[$selection]
|
||||||
|
|
||||||
if (-not $selectedDomain) {
|
if (-not $selectedDomain) {
|
||||||
Write-Error "Invalid selection."
|
throw "Invalid selection."
|
||||||
exit
|
}
|
||||||
}
|
|
||||||
|
|
||||||
# Update script variables based on selected domain
|
$domainController = $selectedDomain.DC
|
||||||
$domainController = $selectedDomain.DC
|
|
||||||
$credential = Get-Credential -Message "Enter AD credentials with replication rights for $($selectedDomain.Name)"
|
|
||||||
|
|
||||||
$domainPrefix = ($selectedDomain.Name -replace "\W", "_")
|
# Validate credentials and replication permissions before attempting DCSync
|
||||||
$baseName = "${domainPrefix}_NTLM_Hashes_$timestamp"
|
$hasADModule = $null -ne (Get-Module -Name ActiveDirectory -ErrorAction SilentlyContinue)
|
||||||
$exportPath = Join-Path -Path $scriptRoot -ChildPath "$baseName.txt"
|
if (-not $hasADModule) {
|
||||||
$compressedFilePath = Join-Path -Path $scriptRoot -ChildPath "$baseName.zip"
|
try { Import-Module ActiveDirectory -ErrorAction Stop; $hasADModule = $true } catch {}
|
||||||
$encryptedFilePath = Join-Path -Path $scriptRoot -ChildPath "$baseName.enc"
|
}
|
||||||
$blobName = "$baseName.enc"
|
|
||||||
|
|
||||||
$ntlmHashes = Get-ADReplAccount -All -Server $domainController -Credential $credential |
|
if ($hasADModule) {
|
||||||
Where-Object { $_.NTHash } |
|
$credential = Get-ValidatedADCredential -DomainName $selectedDomain.Name -Server $domainController
|
||||||
ForEach-Object { [BitConverter]::ToString($_.NTHash).Replace("-", "") } |
|
try {
|
||||||
Sort-Object -Unique
|
$domainInfo = Get-ADDomain -Server $domainController -Credential $credential -ErrorAction Stop
|
||||||
|
Test-ReplicationPermissions -DomainDN $domainInfo.DistinguishedName `
|
||||||
|
-Server $domainController -Credential $credential
|
||||||
|
} catch {
|
||||||
|
throw $_.Exception.Message
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Write-Warning "ActiveDirectory module not available; skipping credential pre-check and replication permission verification."
|
||||||
|
$credential = Get-Credential -Message "Enter AD credentials with replication rights for $($selectedDomain.Name)"
|
||||||
|
if ($null -eq $credential) { throw "Credential prompt was cancelled." }
|
||||||
|
}
|
||||||
|
|
||||||
$ntlmHashes | Out-File -FilePath $exportPath
|
$domainPrefix = ($selectedDomain.Name -replace "\W", "_")
|
||||||
Write-Host "NTLM hashes have been extracted to: $exportPath"
|
$baseName = "${domainPrefix}_NTLM_Hashes_$timestamp"
|
||||||
|
$blobName = "$baseName.enc"
|
||||||
|
|
||||||
# Compress extracted NTLM hashes
|
# Use a temp directory for all sensitive intermediate files so they are
|
||||||
Compress-Archive -Path $exportPath -DestinationPath $compressedFilePath
|
# never written to the installation directory and are always cleaned up.
|
||||||
Write-Host "File has been compressed: $compressedFilePath"
|
$tmpDir = New-Item -ItemType Directory -Path ([System.IO.Path]::Combine(
|
||||||
|
[System.IO.Path]::GetTempPath(), "elysium-extract-" + [System.Guid]::NewGuid())) -Force
|
||||||
|
$exportPath = Join-Path -Path $tmpDir.FullName -ChildPath "$baseName.txt"
|
||||||
|
$compressedFilePath = Join-Path -Path $tmpDir.FullName -ChildPath "$baseName.zip"
|
||||||
|
$encryptedFilePath = Join-Path -Path $tmpDir.FullName -ChildPath "$baseName.enc"
|
||||||
|
$tempDownloadPath = $null
|
||||||
|
|
||||||
# Encrypt the compressed file
|
try {
|
||||||
Protect-FileWithAES -InputFile $compressedFilePath -OutputFile $encryptedFilePath -Passphrase $passphrase
|
$ntlmHashes = Get-ADReplAccount -All -Server $domainController -Credential $credential |
|
||||||
Write-Host "File has been encrypted: $encryptedFilePath"
|
Where-Object { $_.NTHash } |
|
||||||
|
ForEach-Object { [BitConverter]::ToString($_.NTHash).Replace("-", "") } |
|
||||||
|
Sort-Object -Unique
|
||||||
|
|
||||||
# Calculate the local file checksum
|
$ntlmHashes | Out-File -FilePath $exportPath
|
||||||
$localFileChecksum = Get-FileChecksum -Path $encryptedFilePath
|
Write-Host "NTLM hashes have been extracted to temporary file."
|
||||||
|
|
||||||
if ($storageProvider -ieq 'S3') {
|
Compress-Archive -Path $exportPath -DestinationPath $compressedFilePath
|
||||||
# S3-compatible path (e.g., IDrive e2) without requiring AWS Tools
|
Write-Host "File has been compressed."
|
||||||
if ([string]::IsNullOrWhiteSpace($s3BucketName)) { Write-Error 's3BucketName is missing in settings.'; exit }
|
|
||||||
if ([string]::IsNullOrWhiteSpace($s3AccessKeyId) -or [string]::IsNullOrWhiteSpace($s3SecretAccessKey)) { Write-Error 's3AccessKeyId / s3SecretAccessKey missing in settings.'; exit }
|
Protect-FileWithAES -InputFile $compressedFilePath -OutputFile $encryptedFilePath -Passphrase $passphrase
|
||||||
if ([string]::IsNullOrWhiteSpace($s3EndpointUrl)) { Write-Error 's3EndpointUrl is required for S3-compatible storage.'; exit }
|
|
||||||
|
$localFileChecksum = Get-FileChecksum -Path $encryptedFilePath
|
||||||
|
|
||||||
|
if ($storageProvider -ieq 'S3') {
|
||||||
|
if ([string]::IsNullOrWhiteSpace($s3BucketName)) { throw 's3BucketName is missing in settings.' }
|
||||||
|
if ([string]::IsNullOrWhiteSpace($s3AccessKeyId) -or [string]::IsNullOrWhiteSpace($s3SecretAccessKey)) { throw 's3AccessKeyId / s3SecretAccessKey missing in settings.' }
|
||||||
|
if ([string]::IsNullOrWhiteSpace($s3EndpointUrl)) { throw 's3EndpointUrl is required for S3-compatible storage.' }
|
||||||
|
|
||||||
$usedAwsTools = $false
|
$usedAwsTools = $false
|
||||||
if ($s3UseAwsTools) {
|
if ($s3UseAwsTools) {
|
||||||
try {
|
try {
|
||||||
$s3Client = New-S3Client -EndpointUrl $s3EndpointUrl -Region $s3Region -AccessKeyId $s3AccessKeyId -SecretAccessKey $s3SecretAccessKey -ForcePathStyle:$s3ForcePathStyle
|
$s3Client = New-S3Client -EndpointUrl $s3EndpointUrl -Region $s3Region -AccessKeyId $s3AccessKeyId -SecretAccessKey $s3SecretAccessKey -ForcePathStyle:$s3ForcePathStyle
|
||||||
# Upload
|
|
||||||
$putReq = New-Object Amazon.S3.Model.PutObjectRequest -Property @{ BucketName = $s3BucketName; Key = $blobName; FilePath = $encryptedFilePath }
|
$putReq = New-Object Amazon.S3.Model.PutObjectRequest -Property @{ BucketName = $s3BucketName; Key = $blobName; FilePath = $encryptedFilePath }
|
||||||
$null = $s3Client.PutObject($putReq)
|
$null = $s3Client.PutObject($putReq)
|
||||||
Write-Host "Encrypted file uploaded to S3-compatible bucket (AWS Tools): $blobName"
|
Write-Host "Encrypted file uploaded to S3-compatible bucket (AWS Tools): $blobName"
|
||||||
@@ -390,7 +288,6 @@ if ($storageProvider -ieq 'S3') {
|
|||||||
$getResp = $s3Client.GetObject($getReq)
|
$getResp = $s3Client.GetObject($getReq)
|
||||||
$getResp.WriteResponseStreamToFile($tempDownloadPath, $true)
|
$getResp.WriteResponseStreamToFile($tempDownloadPath, $true)
|
||||||
$getResp.Dispose()
|
$getResp.Dispose()
|
||||||
$downloadedFileChecksum = Get-FileChecksum -Path $tempDownloadPath
|
|
||||||
$usedAwsTools = $true
|
$usedAwsTools = $true
|
||||||
} catch {
|
} catch {
|
||||||
Write-Warning "AWS Tools path failed or not available. Falling back to native HTTP (SigV4). Details: $($_.Exception.Message)"
|
Write-Warning "AWS Tools path failed or not available. Falling back to native HTTP (SigV4). Details: $($_.Exception.Message)"
|
||||||
@@ -403,48 +300,49 @@ if ($storageProvider -ieq 'S3') {
|
|||||||
Write-Host "Encrypted file uploaded to S3-compatible bucket: $blobName"
|
Write-Host "Encrypted file uploaded to S3-compatible bucket: $blobName"
|
||||||
$tempDownloadPath = [System.IO.Path]::GetTempFileName()
|
$tempDownloadPath = [System.IO.Path]::GetTempFileName()
|
||||||
Invoke-S3GetToFile -endpointUrl $s3EndpointUrl -bucket $s3BucketName -key $blobName -targetPath $tempDownloadPath -region $s3Region -ak $s3AccessKeyId -sk $s3SecretAccessKey -forcePathStyle:$s3ForcePathStyle
|
Invoke-S3GetToFile -endpointUrl $s3EndpointUrl -bucket $s3BucketName -key $blobName -targetPath $tempDownloadPath -region $s3Region -ak $s3AccessKeyId -sk $s3SecretAccessKey -forcePathStyle:$s3ForcePathStyle
|
||||||
$downloadedFileChecksum = Get-FileChecksum -Path $tempDownloadPath
|
|
||||||
}
|
}
|
||||||
}
|
} else {
|
||||||
else {
|
|
||||||
# Azure Blob Storage path (default)
|
|
||||||
$sas = $sasToken
|
$sas = $sasToken
|
||||||
if ([string]::IsNullOrWhiteSpace($sas)) { Write-Error 'sasToken is missing in settings.'; exit }
|
if ([string]::IsNullOrWhiteSpace($sas)) { throw 'sasToken is missing in settings.' }
|
||||||
$sas = $sas.Trim(); if (-not $sas.StartsWith('?')) { $sas = '?' + $sas }
|
$sas = $sas.Trim(); if (-not $sas.StartsWith('?')) { $sas = '?' + $sas }
|
||||||
try { Import-Module Az.Storage -ErrorAction Stop } catch {}
|
try { Import-Module Az.Storage -ErrorAction Stop } catch {}
|
||||||
$storageContext = New-AzStorageContext -StorageAccountName $storageAccountName -SasToken $sas
|
$storageContext = New-AzStorageContext -StorageAccountName $storageAccountName -SasToken $sas
|
||||||
|
|
||||||
# Ensure container exists
|
|
||||||
$container = Get-AzStorageContainer -Name $containerName -Context $storageContext -ErrorAction SilentlyContinue
|
$container = Get-AzStorageContainer -Name $containerName -Context $storageContext -ErrorAction SilentlyContinue
|
||||||
if (-not $container) { Write-Error "Azure container '$containerName' not found or access denied."; exit }
|
if (-not $container) { throw "Azure container '$containerName' not found or access denied." }
|
||||||
|
|
||||||
# Upload the encrypted file to Azure Blob Storage
|
|
||||||
Set-AzStorageBlobContent -File $encryptedFilePath -Container $containerName -Blob $blobName -Context $storageContext | Out-Null
|
Set-AzStorageBlobContent -File $encryptedFilePath -Container $containerName -Blob $blobName -Context $storageContext | Out-Null
|
||||||
Write-Host "Encrypted file uploaded to Azure Blob Storage: $blobName"
|
Write-Host "Encrypted file uploaded to Azure Blob Storage: $blobName"
|
||||||
|
|
||||||
# Download the blob to a temporary location to verify
|
|
||||||
$tempDownloadPath = [System.IO.Path]::GetTempFileName()
|
$tempDownloadPath = [System.IO.Path]::GetTempFileName()
|
||||||
Get-AzStorageBlobContent -Blob $blobName -Container $containerName -Context $storageContext -Destination $tempDownloadPath -Force | Out-Null
|
Get-AzStorageBlobContent -Blob $blobName -Container $containerName -Context $storageContext -Destination $tempDownloadPath -Force | Out-Null
|
||||||
|
|
||||||
# Calculate the downloaded file checksum
|
|
||||||
$downloadedFileChecksum = Get-FileChecksum -Path $tempDownloadPath
|
|
||||||
}
|
|
||||||
|
|
||||||
# Compare the checksums
|
|
||||||
if ($localFileChecksum -eq $downloadedFileChecksum) {
|
|
||||||
Write-Host "The file was correctly uploaded. Checksum verified."
|
|
||||||
# Clean up local and temporary files only on success
|
|
||||||
Remove-Item -Path $exportPath, $compressedFilePath, $encryptedFilePath, $tempDownloadPath -Force
|
|
||||||
if ($storageProvider -ieq 'S3') {
|
|
||||||
Write-Host "Local and temporary files cleaned up after uploading to S3-compatible storage."
|
|
||||||
} else {
|
|
||||||
Write-Host "Local and temporary files cleaned up after uploading to Azure Blob Storage."
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
else {
|
$downloadedFileChecksum = Get-FileChecksum -Path $tempDownloadPath
|
||||||
Write-Warning "Checksum verification failed. Keeping local artifacts for investigation: $exportPath, $compressedFilePath, $encryptedFilePath"
|
|
||||||
if (Test-Path $tempDownloadPath) { Remove-Item -Path $tempDownloadPath -Force }
|
if ($localFileChecksum -eq $downloadedFileChecksum) {
|
||||||
}
|
Write-Host "The file was correctly uploaded. Checksum verified."
|
||||||
|
Remove-Item -Path $encryptedFilePath -Force
|
||||||
|
Remove-Item -Path $tempDownloadPath -Force
|
||||||
|
if ($storageProvider -ieq 'S3') {
|
||||||
|
Write-Host "Upload to S3-compatible storage completed and verified."
|
||||||
|
} else {
|
||||||
|
Write-Host "Upload to Azure Blob Storage completed and verified."
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Write-Warning "Checksum verification failed. Encrypted file preserved for investigation: $encryptedFilePath"
|
||||||
|
if ($tempDownloadPath -and (Test-Path $tempDownloadPath)) {
|
||||||
|
Remove-Item -Path $tempDownloadPath -Force -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
# Always delete plaintext hashes and compressed archive regardless of outcome.
|
||||||
|
foreach ($f in @($exportPath, $compressedFilePath)) {
|
||||||
|
if ($f -and (Test-Path $f)) {
|
||||||
|
Remove-Item -Path $f -Force -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Write-Host "Script execution completed."
|
Write-Host "Script execution completed."
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
+1
-155
@@ -7,7 +7,7 @@
|
|||||||
##################################################
|
##################################################
|
||||||
## Project: Elysium ##
|
## Project: Elysium ##
|
||||||
## File: Prepare-KHDBStorage.ps1 ##
|
## File: Prepare-KHDBStorage.ps1 ##
|
||||||
## Version: 2.2.0 ##
|
## Version: 2.4.4 ##
|
||||||
## Support: support@cqre.net ##
|
## Support: support@cqre.net ##
|
||||||
##################################################
|
##################################################
|
||||||
|
|
||||||
@@ -100,44 +100,6 @@ function Remove-DirectoryContents {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function Read-KeyValueSettingsFile {
|
|
||||||
param([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 Get-SettingsValue {
|
|
||||||
param(
|
|
||||||
[hashtable]$Settings,
|
|
||||||
[string]$Key
|
|
||||||
)
|
|
||||||
if (-not $Settings) { return $null }
|
|
||||||
if ($Settings.ContainsKey($Key)) { return $Settings[$Key] }
|
|
||||||
return $null
|
|
||||||
}
|
|
||||||
|
|
||||||
function Get-FunctionDefinitionText {
|
|
||||||
param([Parameter(Mandatory = $true)][string]$Name)
|
|
||||||
$cmd = Get-Command -Name $Name -CommandType Function -ErrorAction Stop
|
|
||||||
return $cmd.ScriptBlock.Ast.Extent.Text
|
|
||||||
}
|
|
||||||
|
|
||||||
function Merge-ShardsToFile {
|
function Merge-ShardsToFile {
|
||||||
param(
|
param(
|
||||||
[psobject]$Manifest,
|
[psobject]$Manifest,
|
||||||
@@ -176,27 +138,6 @@ function Get-NormalizedForwardPath {
|
|||||||
return $PathValue.Replace('\', '/').Trim('/')
|
return $PathValue.Replace('\', '/').Trim('/')
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
function Upload-AzureBlob {
|
function Upload-AzureBlob {
|
||||||
param(
|
param(
|
||||||
[string]$Account,
|
[string]$Account,
|
||||||
@@ -232,88 +173,6 @@ function Upload-AzureBlob {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
|
||||||
$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 Invoke-S3HttpUpload {
|
function Invoke-S3HttpUpload {
|
||||||
param(
|
param(
|
||||||
[string]$EndpointUrl,
|
[string]$EndpointUrl,
|
||||||
@@ -356,19 +215,6 @@ function Invoke-S3HttpUpload {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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"
|
|
||||||
}
|
|
||||||
|
|
||||||
function Split-KhdbIntoShards {
|
function Split-KhdbIntoShards {
|
||||||
param(
|
param(
|
||||||
[string]$Source,
|
[string]$Source,
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ Sensitive operations are confined only to the dedicated host. In the third step,
|
|||||||
## Prerequisities
|
## Prerequisities
|
||||||
* **Windows Host:** A Windows machine with PowerShell and DSInternals suite installed.
|
* **Windows Host:** A Windows machine with PowerShell and DSInternals suite installed.
|
||||||
* **Administrative Access:** Local admin privileges on the host for installation and updating.
|
* **Administrative Access:** Local admin privileges on the host for installation and updating.
|
||||||
* **Domain Credentials:** For weak-password testing (option 2), an account with the three replication rights (`Replicating Directory Changes`, `Replicating Directory Changes All`, `Replicating Directory Changes In Filtered Set`) on the domain naming context; Domain Admin also works but is not required. Keep this account disabled and enable only when running tests.
|
* **Domain Credentials:** For weak-password testing (option 2), an account with the three replication rights (`Replicating Directory Changes`, `Replicating Directory Changes All`, `Replicating Directory Changes In Filtered Set`) on **both** the domain naming context **and** `CN=Configuration,DC=…` (which covers the schema NC via inheritance). Domain Admin also works but is not required. See *Least privileges* below for exact delegation steps. Keep this account disabled and enable only when running tests.
|
||||||
* **Network Requirements:** A stable connection to the domain controller in each tested AD domain and internet access (specific hostnames/IP addresses will be provided).
|
* **Network Requirements:** A stable connection to the domain controller in each tested AD domain and internet access (specific hostnames/IP addresses will be provided).
|
||||||
|
|
||||||
## Versioning and Releases
|
## Versioning and Releases
|
||||||
@@ -58,20 +58,38 @@ The tool connects to the selected Domain Controller and compares accounts agains
|
|||||||
The KHDB file is consumed by DSInternals as a sorted hash list with one NT hash per line (for example `HASH`). Do not include `:count` suffixes in `khdb.txt`; the packaging and update scripts normalize legacy `HASH:count` input to the hash-only format automatically.
|
The KHDB file is consumed by DSInternals as a sorted hash list with one NT hash per line (for example `HASH`). Do not include `:count` suffixes in `khdb.txt`; the packaging and update scripts normalize legacy `HASH:count` input to the hash-only format automatically.
|
||||||
|
|
||||||
#### Least privileges for password-quality testing
|
#### Least privileges for password-quality testing
|
||||||
The DSInternals cmdlets (`Get-ADReplAccount`/`Test-PasswordQuality`) pull replicated password data, which requires DCSync-style rights. The account that runs option 2 does not have to be a Domain Admin if it has these permissions on the domain naming context:
|
The DSInternals cmdlets (`Get-ADReplAccount`/`Test-PasswordQuality`) pull replicated password data using the MS-DRSR (DCSync) protocol. The account does not need to be a Domain Admin; delegate these three extended rights on **two** AD objects:
|
||||||
|
|
||||||
|
| Object | Why |
|
||||||
|
|--------|-----|
|
||||||
|
| Domain NC root — e.g. `DC=admin,DC=lan` | Required to replicate account password hashes |
|
||||||
|
| Configuration NC root — e.g. `CN=Configuration,DC=admin,DC=lan` | Required by DSInternals 7.0+ to fetch the AD schema via DRS before replication; covers the schema NC (`CN=Schema,CN=Configuration,DC=…`) via inheritance |
|
||||||
|
|
||||||
|
Rights to delegate on both objects:
|
||||||
- `Replicating Directory Changes`
|
- `Replicating Directory Changes`
|
||||||
- `Replicating Directory Changes All`
|
- `Replicating Directory Changes All`
|
||||||
- `Replicating Directory Changes In Filtered Set` (needed on 2008 R2+ to read password hashes)
|
- `Replicating Directory Changes In Filtered Set` (required on 2008 R2+ to read password hashes)
|
||||||
|
|
||||||
To delegate, enable Advanced Features in ADUC, right-click the domain, choose *Delegate Control…*, pick the service account, select *Create a custom task*, apply to *This object and all descendant objects*, and tick the three replication permissions above. Keep this account disabled and only activate it for scheduled tests.
|
**To delegate in ADUC:** enable *Advanced Features*, right-click each object above, choose *Properties* > *Security* > *Advanced* > *Add*, select the service account, set *Applies to: This object only*, and tick the three rights. Repeat for both objects.
|
||||||
|
|
||||||
|
**To delegate via `dsacls`** (replace `DC=admin,DC=lan` and `DOMAIN\svc` as appropriate):
|
||||||
|
```powershell
|
||||||
|
foreach ($nc in @('DC=admin,DC=lan', 'CN=Configuration,DC=admin,DC=lan')) {
|
||||||
|
dsacls $nc /I:T /G "DOMAIN\svc:CA;Replicating Directory Changes"
|
||||||
|
dsacls $nc /I:T /G "DOMAIN\svc:CA;Replicating Directory Changes All"
|
||||||
|
dsacls $nc /I:T /G "DOMAIN\svc:CA;Replicating Directory Changes In Filtered Set"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep the service account disabled and only activate it for scheduled tests.
|
||||||
|
|
||||||
#### Common errors
|
#### Common errors
|
||||||
- `The server has rejected the client credentials.` or `Credentials ... were rejected`:
|
- `The server has rejected the client credentials.` or `Credentials ... were rejected`:
|
||||||
The supplied username/password is invalid for the selected domain controller, or the session is not running in the expected domain context. Re-run and provide valid domain credentials.
|
The supplied username/password is invalid for the selected domain controller, or the session is not running in the expected domain context. Re-run and provide valid domain credentials.
|
||||||
- `Account '<user>' is missing the following replication permissions ...`:
|
- `Account '<user>' is missing the following replication permissions ...`:
|
||||||
Starting with v2.2.0, the script pre-validates the three required replication extended rights against the domain object ACL before attempting DCSync. If this error appears, delegate the listed rights (see *Least privileges* above) and retry.
|
Starting with v2.2.0, the script pre-validates the three required replication extended rights against the domain object ACL before attempting DCSync. If this error appears, delegate the listed rights (see *Least privileges* above) and retry.
|
||||||
- `Get-ADReplAccount: Access is denied`:
|
- `Replication access was denied` (from `Get-ADReplAccount`):
|
||||||
Credentials are valid, but the account does not have the three replication permissions listed above. This error should now be rare because the pre-check catches most permission issues early; if it still occurs, verify the account is not restricted by an additional conditional access or Group Policy setting.
|
DSInternals 7.0+ fetches the AD schema via DRS (`GetNCChanges`) as its first step, before replicating any accounts. This fails if the service account lacks `Replicating Directory Changes` on the **schema NC** (`CN=Schema,CN=Configuration,DC=…`). Grant the three rights on `CN=Configuration,DC=…` (covers schema NC via inheritance) in addition to the domain NC — see *Least privileges* above. The pre-flight permission check in v2.4.4+ catches this mismatch before attempting replication.
|
||||||
- `Only FIPS certified cryptographic algorithms are enabled in .NET`:
|
- `Only FIPS certified cryptographic algorithms are enabled in .NET`:
|
||||||
This warning comes from DSInternals under FIPS-enforced environments. Hash-quality operations that rely on MD5 may be limited.
|
This warning comes from DSInternals under FIPS-enforced environments. Hash-quality operations that rely on MD5 may be limited.
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
# Settings for Elysium Tool
|
|
||||||
|
|
||||||
# General Settings
|
|
||||||
$Global:ToolRepositoryUrl = "https://example.com/git/elysium.git"
|
|
||||||
|
|
||||||
# KHDB Update Settings
|
|
||||||
$Global:KnownHashesBaseUrl = "https://example.com/known-hashes/"
|
|
||||||
$Global:LocalKnownHashesPath = "C:\Elysium\known-hashes"
|
|
||||||
|
|
||||||
# Test Weak AD Passwords Settings
|
|
||||||
$Global:DomainAdminUsernames = @{
|
|
||||||
"Domain1" = "admin1";
|
|
||||||
"Domain2" = "admin2";
|
|
||||||
# Add more domains and usernames as needed
|
|
||||||
}
|
|
||||||
$Global:PdfReportPath = "C:\Elysium\Reports"
|
|
||||||
|
|
||||||
# Extract and Send Hashes Settings
|
|
||||||
$Global:HashesExportPath = "C:\Elysium\Hashes"
|
|
||||||
$Global:ToolProviderUploadUrl = "https://upload.example.com/hashes"
|
|
||||||
|
|
||||||
# Any additional settings...
|
|
||||||
+94
-134
@@ -8,7 +8,7 @@
|
|||||||
##################################################
|
##################################################
|
||||||
## Project: Elysium ##
|
## Project: Elysium ##
|
||||||
## File: Test-WeakADPasswords.ps1 ##
|
## File: Test-WeakADPasswords.ps1 ##
|
||||||
## Version: 2.2.0 ##
|
## Version: 2.4.4 ##
|
||||||
## Support: support@cqre.net ##
|
## Support: support@cqre.net ##
|
||||||
##################################################
|
##################################################
|
||||||
|
|
||||||
@@ -21,10 +21,13 @@ Weak AD password finder component of Elysium tool.
|
|||||||
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.
|
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.
|
||||||
#>
|
#>
|
||||||
|
|
||||||
# Enable verbose output
|
|
||||||
$ErrorActionPreference = 'Stop'
|
$ErrorActionPreference = 'Stop'
|
||||||
Set-StrictMode -Version Latest
|
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"
|
$VerbosePreference = "SilentlyContinue"
|
||||||
|
|
||||||
$scriptRoot = $PSScriptRoot
|
$scriptRoot = $PSScriptRoot
|
||||||
@@ -92,7 +95,7 @@ function Invoke-UsageBeacon {
|
|||||||
if ($normalizedMethod -in @('POST', 'PUT')) {
|
if ($normalizedMethod -in @('POST', 'PUT')) {
|
||||||
$payload = [ordered]@{
|
$payload = [ordered]@{
|
||||||
script = 'Test-WeakADPasswords'
|
script = 'Test-WeakADPasswords'
|
||||||
version = '1.4.5'
|
version = $ElysiumVersion
|
||||||
ranAtUtc = (Get-Date).ToUniversalTime().ToString('o')
|
ranAtUtc = (Get-Date).ToUniversalTime().ToString('o')
|
||||||
}
|
}
|
||||||
if (-not [string]::IsNullOrWhiteSpace($InstanceId)) {
|
if (-not [string]::IsNullOrWhiteSpace($InstanceId)) {
|
||||||
@@ -124,32 +127,9 @@ $footer = "`r`n==== End of Report ===="
|
|||||||
|
|
||||||
Start-TestTranscript -BasePath $scriptRoot
|
Start-TestTranscript -BasePath $scriptRoot
|
||||||
try {
|
try {
|
||||||
# Import settings
|
|
||||||
Write-Verbose "Loading settings..."
|
Write-Verbose "Loading settings..."
|
||||||
$ElysiumSettings = @{}
|
$ElysiumSettings = Read-ElysiumSettings -ScriptRoot $scriptRoot
|
||||||
$settingsPath = Join-Path -Path $scriptRoot -ChildPath "ElysiumSettings.txt"
|
|
||||||
|
|
||||||
# Ensure the settings file exists
|
|
||||||
if (-not (Test-Path $settingsPath)) {
|
|
||||||
Write-Error "Settings file not found at $settingsPath"
|
|
||||||
exit
|
|
||||||
}
|
|
||||||
|
|
||||||
# Load settings from file
|
|
||||||
try {
|
|
||||||
Get-Content $settingsPath | ForEach-Object {
|
|
||||||
if (-not [string]::IsNullOrWhiteSpace($_) -and -not $_.StartsWith("#")) {
|
|
||||||
$keyValue = $_ -split '=', 2
|
|
||||||
if ($keyValue.Count -eq 2) {
|
|
||||||
$ElysiumSettings[$keyValue[0].Trim()] = $keyValue[1].Trim()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Write-Verbose "Settings loaded successfully."
|
Write-Verbose "Settings loaded successfully."
|
||||||
} catch {
|
|
||||||
Write-Error ("An error occurred while loading settings: {0}" -f $_.Exception.Message)
|
|
||||||
exit
|
|
||||||
}
|
|
||||||
|
|
||||||
$usageBeaconUrl = $ElysiumSettings['UsageBeaconUrl']
|
$usageBeaconUrl = $ElysiumSettings['UsageBeaconUrl']
|
||||||
$usageBeaconMethod = $ElysiumSettings['UsageBeaconMethod']
|
$usageBeaconMethod = $ElysiumSettings['UsageBeaconMethod']
|
||||||
@@ -372,7 +352,14 @@ function Import-CompatModule {
|
|||||||
|
|
||||||
$nonFipsErrors = @($importErrors | Where-Object { $_.Exception.Message -notmatch 'Only FIPS certified cryptographic algorithms are enabled in \.NET' })
|
$nonFipsErrors = @($importErrors | Where-Object { $_.Exception.Message -notmatch 'Only FIPS certified cryptographic algorithms are enabled in \.NET' })
|
||||||
if ($nonFipsErrors.Count -gt 0) {
|
if ($nonFipsErrors.Count -gt 0) {
|
||||||
Write-Warning ("DSInternals import reported non-fatal warning(s): {0}" -f $nonFipsErrors[0].Exception.Message)
|
$nonFipsMsg = $nonFipsErrors[0].Exception.Message
|
||||||
|
if ($nonFipsMsg -match 'Zone\.Identifier|alternate data stream') {
|
||||||
|
$dsModule = Get-Module -Name DSInternals -ErrorAction SilentlyContinue
|
||||||
|
if (-not $dsModule) { $dsModule = Get-Module -ListAvailable -Name DSInternals -ErrorAction SilentlyContinue | Select-Object -First 1 }
|
||||||
|
$dsPath = if ($dsModule) { $dsModule.ModuleBase } else { '<DSInternals module path>' }
|
||||||
|
throw ("DSInternals native DLL is blocked by Windows (Zone.Identifier). Run the following on the target machine and retry:`n Get-ChildItem -Path '$dsPath' -Recurse | Unblock-File")
|
||||||
|
}
|
||||||
|
Write-Warning ("DSInternals import reported non-fatal warning(s): {0}" -f $nonFipsMsg)
|
||||||
}
|
}
|
||||||
|
|
||||||
Write-Verbose ("Imported module '{0}' (Core={1}, Windows={2})" -f $Name, $runningInPSCore, $onWindows)
|
Write-Verbose ("Imported module '{0}' (Core={1}, Windows={2})" -f $Name, $runningInPSCore, $onWindows)
|
||||||
@@ -405,6 +392,28 @@ try {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Version check: v6.2 was unsigned (blocks native DLLs, causes replication failures);
|
||||||
|
# v7.0 fixes intermittent CRC errors mid-replication and Test-PasswordQuality result truncation.
|
||||||
|
$dsInternalsVersion = (Get-Module -Name DSInternals).Version
|
||||||
|
$minimumVersion = [version]'7.0'
|
||||||
|
$unsignedVersion = [version]'6.2'
|
||||||
|
if ($dsInternalsVersion -eq $unsignedVersion) {
|
||||||
|
Write-Warning ("DSInternals {0} is not digitally signed, which blocks its native DLLs and causes replication failures. Update to v7.0+: Install-Module DSInternals -Force -AllowClobber" -f $dsInternalsVersion)
|
||||||
|
} elseif ($dsInternalsVersion -lt $minimumVersion) {
|
||||||
|
$resp = Read-Host ("DSInternals {0} is installed; v7.0 fixes intermittent replication CRC errors and result truncation. Update now? [Y/N]" -f $dsInternalsVersion)
|
||||||
|
if ($resp -match '^(?i:y|yes)$') {
|
||||||
|
try {
|
||||||
|
# Install-Module -Force is used instead of Update-Module to avoid a PowerShellGet bug
|
||||||
|
# where null PublishedDate metadata causes "cannot convert null to type system.datetime"
|
||||||
|
Install-Module -Name DSInternals -Force -AllowClobber -ErrorAction Stop
|
||||||
|
Write-Host '[+] DSInternals updated. Please re-run the script to load the new version.'
|
||||||
|
exit 0
|
||||||
|
} catch {
|
||||||
|
Write-Warning ("DSInternals update failed: {0}" -f $_.Exception.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
# Resolve KHDB path with fallbacks
|
# Resolve KHDB path with fallbacks
|
||||||
$installationPath = $ElysiumSettings["InstallationPath"]
|
$installationPath = $ElysiumSettings["InstallationPath"]
|
||||||
if ([string]::IsNullOrWhiteSpace($installationPath)) { $installationPath = $scriptRoot }
|
if ([string]::IsNullOrWhiteSpace($installationPath)) { $installationPath = $scriptRoot }
|
||||||
@@ -561,104 +570,6 @@ function Resolve-DSInternalsWeakHashFile {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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'
|
|
||||||
}
|
|
||||||
|
|
||||||
# Collect caller SID + direct group SIDs so we can match ACEs below
|
|
||||||
$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, MemberOf -ErrorAction Stop
|
|
||||||
[void]$callerSids.Add($adUser.SID.Value)
|
|
||||||
foreach ($groupDN in @($adUser.MemberOf)) {
|
|
||||||
try {
|
|
||||||
$g = Get-ADGroup -Identity $groupDN -Server $Server -Credential $Credential `
|
|
||||||
-Properties SID -ErrorAction Stop
|
|
||||||
[void]$callerSids.Add($g.SID.Value)
|
|
||||||
} catch { }
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
Write-Warning ("Could not resolve account SIDs for replication permission pre-check: {0}. Skipping." -f $_.Exception.Message)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
# Read the domain object's DACL via ADSI so we can use the provided credential
|
|
||||||
$acl = $null
|
|
||||||
try {
|
|
||||||
$de = New-Object System.DirectoryServices.DirectoryEntry(
|
|
||||||
"LDAP://$Server/$DomainDN",
|
|
||||||
$Credential.UserName,
|
|
||||||
$Credential.GetNetworkCredential().Password
|
|
||||||
)
|
|
||||||
# Translate all trustees to SID form for consistent comparison
|
|
||||||
$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-Verbose ("Replication permission pre-check passed for '{0}'." -f $Credential.UserName)
|
|
||||||
}
|
|
||||||
|
|
||||||
# Function to test for weak AD passwords
|
# Function to test for weak AD passwords
|
||||||
function Test-WeakADPasswords {
|
function Test-WeakADPasswords {
|
||||||
param (
|
param (
|
||||||
@@ -693,9 +604,10 @@ function Test-WeakADPasswords {
|
|||||||
Write-Verbose ("Using credential supplied by caller: {0}" -f $credential.UserName)
|
Write-Verbose ("Using credential supplied by caller: {0}" -f $credential.UserName)
|
||||||
}
|
}
|
||||||
|
|
||||||
# Verify the account has the three replication extended rights before attempting DCSync
|
# Pre-flight checks before attempting DCSync
|
||||||
try {
|
try {
|
||||||
$domainInfo = Get-ADDomain -Server $selectedDomain["DC"] -Credential $credential -ErrorAction Stop
|
$domainInfo = Get-ADDomain -Server $selectedDomain["DC"] -Credential $credential -ErrorAction Stop
|
||||||
|
Test-DCClockSkew -Server $selectedDomain["DC"] -Credential $credential
|
||||||
Test-ReplicationPermissions -DomainDN $domainInfo.DistinguishedName `
|
Test-ReplicationPermissions -DomainDN $domainInfo.DistinguishedName `
|
||||||
-Server $selectedDomain["DC"] -Credential $credential
|
-Server $selectedDomain["DC"] -Credential $credential
|
||||||
} catch {
|
} catch {
|
||||||
@@ -719,16 +631,64 @@ function Test-WeakADPasswords {
|
|||||||
$testResults = $accounts | Test-PasswordQuality -WeakPasswordHashesSortedFile $resolvedHashFile.Path
|
$testResults = $accounts | Test-PasswordQuality -WeakPasswordHashesSortedFile $resolvedHashFile.Path
|
||||||
Write-Verbose "Password quality test completed."
|
Write-Verbose "Password quality test completed."
|
||||||
} catch {
|
} catch {
|
||||||
$message = $_.Exception.Message
|
$ex = $_.Exception
|
||||||
if ($message -match 'Access is denied') {
|
$diagLines = [System.Collections.Generic.List[string]]::new()
|
||||||
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)
|
$diagLines.Add('========================================')
|
||||||
return
|
$diagLines.Add('ELYSLUM DCSYNC DIAGNOSTIC DUMP')
|
||||||
|
$diagLines.Add('========================================')
|
||||||
|
$diagLines.Add("Timestamp : $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')")
|
||||||
|
$diagLines.Add("Script Ver : $ElysiumVersion")
|
||||||
|
$diagLines.Add("PS Version : $($PSVersionTable.PSVersion)")
|
||||||
|
$diagLines.Add("PS Edition : $($PSVersionTable.PSEdition)")
|
||||||
|
$diagLines.Add("DSInternals : $((Get-Module -Name DSInternals).Version)")
|
||||||
|
$diagLines.Add("DC : $($selectedDomain['DC'])")
|
||||||
|
$diagLines.Add("Domain : $($selectedDomain.Name)")
|
||||||
|
$diagLines.Add("Account : $($credential.UserName)")
|
||||||
|
$diagLines.Add("DomainDN : $($domainInfo.DistinguishedName)")
|
||||||
|
$diagLines.Add("SchemaDN : CN=Schema,CN=Configuration,$($domainInfo.DistinguishedName)")
|
||||||
|
$diagLines.Add('')
|
||||||
|
$diagLines.Add('--- EXCEPTION CHAIN ---')
|
||||||
|
$depth = 0
|
||||||
|
$currentEx = $ex
|
||||||
|
while ($null -ne $currentEx) {
|
||||||
|
$diagLines.Add("Exception $depth : $($currentEx.GetType().FullName)")
|
||||||
|
$diagLines.Add(" Message : $($currentEx.Message)")
|
||||||
|
$diagLines.Add(" HResult : 0x$($currentEx.HResult.ToString('X8'))")
|
||||||
|
$diagLines.Add(" Source : $($currentEx.Source)")
|
||||||
|
if ($currentEx.TargetSite) {
|
||||||
|
$diagLines.Add(" TargetSite : $($currentEx.TargetSite)")
|
||||||
}
|
}
|
||||||
if ($message -match 'rejected the client credentials|unknown user name|bad password|logon failure') {
|
if ($currentEx.StackTrace) {
|
||||||
|
$diagLines.Add(" StackTrace :`n$($currentEx.StackTrace -replace '^', ' ')")
|
||||||
|
}
|
||||||
|
$diagLines.Add('')
|
||||||
|
$currentEx = $currentEx.InnerException
|
||||||
|
$depth++
|
||||||
|
}
|
||||||
|
$diagLines.Add('--- END DIAGNOSTIC DUMP ---')
|
||||||
|
|
||||||
|
$diagText = $diagLines -join "`r`n"
|
||||||
|
Write-Host $diagText -ForegroundColor Red
|
||||||
|
|
||||||
|
$diagPath = Join-Path -Path $reportPathBase -ChildPath "dcsync-diag-$timestamp.txt"
|
||||||
|
try {
|
||||||
|
New-Item -ItemType Directory -Path $reportPathBase -Force | Out-Null
|
||||||
|
[System.IO.File]::WriteAllText($diagPath, $diagText, [System.Text.Encoding]::UTF8)
|
||||||
|
Write-Host ("Diagnostic dump written to: {0}" -f $diagPath)
|
||||||
|
} catch {
|
||||||
|
Write-Warning ("Could not write diagnostic dump to disk: {0}" -f $_.Exception.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Still emit the concise error for the operator
|
||||||
|
$message = $ex.Message
|
||||||
|
if ($message -match 'Replication access was denied|Access is denied') {
|
||||||
|
Write-Error ("Replication access denied from '{0}' using '{1}'.`n`nDSInternals 7.0 fetches the AD schema via DRS before replicating accounts. The schema NC has its own ACL.`nGrant the 3 DCSync extended rights on BOTH:`n 1. {2} (domain NC - for accounts)`n 2. CN=Configuration,{2} (config NC - covers schema NC via inheritance)`n`nIn ADUC: right-click each object > Properties > Security > Advanced > Add the extended rights for '{1}'." -f `
|
||||||
|
$selectedDomain["DC"], $credential.UserName, $domainInfo.DistinguishedName)
|
||||||
|
} elseif ($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"])
|
Write-Error ("Credentials for '{0}' were rejected by '{1}'. Re-run and provide valid domain credentials." -f $credential.UserName, $selectedDomain["DC"])
|
||||||
return
|
} else {
|
||||||
}
|
|
||||||
Write-Error ("An error occurred while testing passwords: {0}" -f $message)
|
Write-Error ("An error occurred while testing passwords: {0}" -f $message)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
} finally {
|
} finally {
|
||||||
if ($resolvedHashFile -and $resolvedHashFile.IsTemporary -and (Test-Path -LiteralPath $resolvedHashFile.Path)) {
|
if ($resolvedHashFile -and $resolvedHashFile.IsTemporary -and (Test-Path -LiteralPath $resolvedHashFile.Path)) {
|
||||||
|
|||||||
+1
-1
@@ -7,7 +7,7 @@
|
|||||||
##################################################
|
##################################################
|
||||||
## Project: Elysium ##
|
## Project: Elysium ##
|
||||||
## File: Uninstall.ps1 ##
|
## File: Uninstall.ps1 ##
|
||||||
## Version: 2.2.0 ##
|
## Version: 2.4.4 ##
|
||||||
## Support: support@cqre.net ##
|
## Support: support@cqre.net ##
|
||||||
##################################################
|
##################################################
|
||||||
|
|
||||||
|
|||||||
+76
-239
@@ -7,7 +7,7 @@
|
|||||||
##################################################
|
##################################################
|
||||||
## Project: Elysium ##
|
## Project: Elysium ##
|
||||||
## File: Update-KHDB.ps1 ##
|
## File: Update-KHDB.ps1 ##
|
||||||
## Version: 2.2.0 ##
|
## Version: 2.4.4 ##
|
||||||
## Support: support@cqre.net ##
|
## Support: support@cqre.net ##
|
||||||
##################################################
|
##################################################
|
||||||
|
|
||||||
@@ -50,21 +50,6 @@ function Stop-UpdateTranscript {
|
|||||||
try { Stop-Transcript | Out-Null } catch {}
|
try { Stop-Transcript | Out-Null } catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
function Read-ElysiumSettings {
|
|
||||||
$settings = @{}
|
|
||||||
$settingsPath = Join-Path -Path $scriptRoot -ChildPath 'ElysiumSettings.txt'
|
|
||||||
if (-not (Test-Path $settingsPath)) { throw "Settings file not found at $settingsPath" }
|
|
||||||
Get-Content $settingsPath | ForEach-Object {
|
|
||||||
if ($_ -and -not $_.Trim().StartsWith('#')) {
|
|
||||||
$kv = $_ -split '=', 2
|
|
||||||
if ($kv.Count -eq 2) {
|
|
||||||
$settings[$kv[0].Trim()] = $kv[1].Trim().Trim("'")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return $settings
|
|
||||||
}
|
|
||||||
|
|
||||||
function Get-InstallationPath([hashtable]$settings) {
|
function Get-InstallationPath([hashtable]$settings) {
|
||||||
$p = $settings['InstallationPath']
|
$p = $settings['InstallationPath']
|
||||||
if ([string]::IsNullOrWhiteSpace($p)) { return $scriptRoot }
|
if ([string]::IsNullOrWhiteSpace($p)) { return $scriptRoot }
|
||||||
@@ -76,218 +61,10 @@ function New-HttpClient {
|
|||||||
Add-Type -AssemblyName System.Net.Http -ErrorAction SilentlyContinue
|
Add-Type -AssemblyName System.Net.Http -ErrorAction SilentlyContinue
|
||||||
$client = [System.Net.Http.HttpClient]::new()
|
$client = [System.Net.Http.HttpClient]::new()
|
||||||
$client.Timeout = [TimeSpan]::FromSeconds(600)
|
$client.Timeout = [TimeSpan]::FromSeconds(600)
|
||||||
$client.DefaultRequestHeaders.UserAgent.ParseAdd('Elysium/2.1.1 (+Update-KHDB)')
|
$client.DefaultRequestHeaders.UserAgent.ParseAdd("Elysium/$ElysiumVersion (+Update-KHDB)")
|
||||||
return $client
|
return $client
|
||||||
}
|
}
|
||||||
|
|
||||||
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('/')
|
|
||||||
$uriBuilder = [System.UriBuilder]::new("https://$Account.blob.core.windows.net/$Container/$normalizedBlob")
|
|
||||||
$uriBuilder.Query = $sas.TrimStart('?')
|
|
||||||
return $uriBuilder.Uri.AbsoluteUri
|
|
||||||
}
|
|
||||||
|
|
||||||
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 Get-FunctionDefinitionText {
|
|
||||||
param([Parameter(Mandatory = $true)][string]$Name)
|
|
||||||
$cmd = Get-Command -Name $Name -CommandType Function -ErrorAction Stop
|
|
||||||
return $cmd.ScriptBlock.Ast.Extent.Text
|
|
||||||
}
|
|
||||||
|
|
||||||
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))
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
|
||||||
$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 Invoke-S3HttpDownloadWithRetry {
|
|
||||||
param(
|
|
||||||
[string]$EndpointUrl,
|
|
||||||
[string]$Bucket,
|
|
||||||
[string]$Key,
|
|
||||||
[string]$TargetPath,
|
|
||||||
[string]$Region,
|
|
||||||
[string]$AccessKeyId,
|
|
||||||
[string]$SecretAccessKey,
|
|
||||||
[bool]$ForcePathStyle,
|
|
||||||
[string]$Activity
|
|
||||||
)
|
|
||||||
|
|
||||||
Add-Type -AssemblyName System.Net.Http -ErrorAction SilentlyContinue
|
|
||||||
[System.Net.Http.HttpClient]$client = [System.Net.Http.HttpClient]::new()
|
|
||||||
$retries = 5
|
|
||||||
$delay = 2
|
|
||||||
try {
|
|
||||||
for ($attempt = 0; $attempt -lt $retries; $attempt++) {
|
|
||||||
$request = $null
|
|
||||||
try {
|
|
||||||
$uri = BuildS3Uri -endpointUrl $EndpointUrl -bucket $Bucket -key $Key -forcePathStyle $ForcePathStyle
|
|
||||||
$payloadHash = (Get-HashHex (Get-Bytes ''))
|
|
||||||
$headers = BuildAuthHeaders -method 'GET' -uri $uri -region $Region -accessKey $AccessKeyId -secretKey $SecretAccessKey -payloadHash $payloadHash
|
|
||||||
$request = [System.Net.Http.HttpRequestMessage]::new([System.Net.Http.HttpMethod]::Get, $uri)
|
|
||||||
foreach ($kvp in $headers.GetEnumerator()) {
|
|
||||||
$request.Headers.TryAddWithoutValidation($kvp.Key, $kvp.Value) | Out-Null
|
|
||||||
}
|
|
||||||
|
|
||||||
$response = $client.SendAsync($request, [System.Net.Http.HttpCompletionOption]::ResponseHeadersRead).GetAwaiter().GetResult()
|
|
||||||
$null = $response.EnsureSuccessStatusCode()
|
|
||||||
|
|
||||||
$totalBytes = $response.Content.Headers.ContentLength
|
|
||||||
$stream = $response.Content.ReadAsStreamAsync().Result
|
|
||||||
$tmpPath = $TargetPath
|
|
||||||
$fs = [System.IO.File]::Create($tmpPath)
|
|
||||||
try {
|
|
||||||
$buffer = New-Object byte[] 8192
|
|
||||||
$totalRead = 0
|
|
||||||
while (($read = $stream.Read($buffer, 0, $buffer.Length)) -gt 0) {
|
|
||||||
$fs.Write($buffer, 0, $read)
|
|
||||||
$totalRead += $read
|
|
||||||
if ($totalBytes) {
|
|
||||||
$pct = ($totalRead * 100.0) / $totalBytes
|
|
||||||
Write-Progress -Activity $Activity -Status ("{0:N2}% Complete" -f $pct) -PercentComplete $pct
|
|
||||||
} else {
|
|
||||||
Write-Progress -Activity $Activity -Status ("Downloaded {0:N0} bytes" -f $totalRead) -PercentComplete 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
$fs.Close()
|
|
||||||
$stream.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($response) { $response.Dispose() }
|
|
||||||
Write-Progress -Activity $Activity -Completed -Status 'Completed'
|
|
||||||
return
|
|
||||||
} catch {
|
|
||||||
if ($attempt -lt ($retries - 1)) {
|
|
||||||
Write-Warning "Download of '$Key' failed (attempt $($attempt + 1)/$retries): $($_.Exception.Message). Retrying in ${delay}s..."
|
|
||||||
Start-Sleep -Seconds $delay
|
|
||||||
$delay = [Math]::Min($delay * 2, 30)
|
|
||||||
} else {
|
|
||||||
throw
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if ($request) { $request.Dispose() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
$client.Dispose()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function Invoke-DownloadWithRetry {
|
function Invoke-DownloadWithRetry {
|
||||||
param(
|
param(
|
||||||
[System.Net.Http.HttpClient]$Client,
|
[System.Net.Http.HttpClient]$Client,
|
||||||
@@ -342,6 +119,79 @@ function Invoke-DownloadWithRetry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Invoke-S3HttpDownloadWithRetry {
|
||||||
|
param(
|
||||||
|
[string]$EndpointUrl,
|
||||||
|
[string]$Bucket,
|
||||||
|
[string]$Key,
|
||||||
|
[string]$TargetPath,
|
||||||
|
[string]$Region,
|
||||||
|
[string]$AccessKeyId,
|
||||||
|
[string]$SecretAccessKey,
|
||||||
|
[bool]$ForcePathStyle,
|
||||||
|
[string]$Activity
|
||||||
|
)
|
||||||
|
|
||||||
|
Add-Type -AssemblyName System.Net.Http -ErrorAction SilentlyContinue
|
||||||
|
[System.Net.Http.HttpClient]$client = [System.Net.Http.HttpClient]::new()
|
||||||
|
$retries = 5
|
||||||
|
$delay = 2
|
||||||
|
try {
|
||||||
|
for ($attempt = 0; $attempt -lt $retries; $attempt++) {
|
||||||
|
$request = $null
|
||||||
|
try {
|
||||||
|
$uri = BuildS3Uri -endpointUrl $EndpointUrl -bucket $Bucket -key $Key -forcePathStyle $ForcePathStyle
|
||||||
|
$payloadHash = (Get-HashHex (Get-Bytes ''))
|
||||||
|
$headers = BuildAuthHeaders -method 'GET' -uri $uri -region $Region -accessKey $AccessKeyId -secretKey $SecretAccessKey -payloadHash $payloadHash
|
||||||
|
$request = [System.Net.Http.HttpRequestMessage]::new([System.Net.Http.HttpMethod]::Get, $uri)
|
||||||
|
foreach ($kvp in $headers.GetEnumerator()) {
|
||||||
|
$request.Headers.TryAddWithoutValidation($kvp.Key, $kvp.Value) | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = $client.SendAsync($request, [System.Net.Http.HttpCompletionOption]::ResponseHeadersRead).GetAwaiter().GetResult()
|
||||||
|
$null = $response.EnsureSuccessStatusCode()
|
||||||
|
|
||||||
|
$totalBytes = $response.Content.Headers.ContentLength
|
||||||
|
$stream = $response.Content.ReadAsStreamAsync().Result
|
||||||
|
$fs = [System.IO.File]::Create($TargetPath)
|
||||||
|
try {
|
||||||
|
$buffer = New-Object byte[] 8192
|
||||||
|
$totalRead = 0
|
||||||
|
while (($read = $stream.Read($buffer, 0, $buffer.Length)) -gt 0) {
|
||||||
|
$fs.Write($buffer, 0, $read)
|
||||||
|
$totalRead += $read
|
||||||
|
if ($totalBytes) {
|
||||||
|
$pct = ($totalRead * 100.0) / $totalBytes
|
||||||
|
Write-Progress -Activity $Activity -Status ("{0:N2}% Complete" -f $pct) -PercentComplete $pct
|
||||||
|
} else {
|
||||||
|
Write-Progress -Activity $Activity -Status ("Downloaded {0:N0} bytes" -f $totalRead) -PercentComplete 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
$fs.Close()
|
||||||
|
$stream.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($response) { $response.Dispose() }
|
||||||
|
Write-Progress -Activity $Activity -Completed -Status 'Completed'
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
if ($attempt -lt ($retries - 1)) {
|
||||||
|
Write-Warning "Download of '$Key' failed (attempt $($attempt + 1)/$retries): $($_.Exception.Message). Retrying in ${delay}s..."
|
||||||
|
Start-Sleep -Seconds $delay
|
||||||
|
$delay = [Math]::Min($delay * 2, 30)
|
||||||
|
} else {
|
||||||
|
throw
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if ($request) { $request.Dispose() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
$client.Dispose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function Get-FileSha256Lower {
|
function Get-FileSha256Lower {
|
||||||
param([string]$Path)
|
param([string]$Path)
|
||||||
if (-not (Test-Path -LiteralPath $Path)) { throw "File not found: $Path" }
|
if (-not (Test-Path -LiteralPath $Path)) { throw "File not found: $Path" }
|
||||||
@@ -387,19 +237,6 @@ function Get-RelativePath {
|
|||||||
return $relativePath.Replace('/', [System.IO.Path]::DirectorySeparatorChar)
|
return $relativePath.Replace('/', [System.IO.Path]::DirectorySeparatorChar)
|
||||||
}
|
}
|
||||||
|
|
||||||
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"
|
|
||||||
}
|
|
||||||
|
|
||||||
function Load-Manifest {
|
function Load-Manifest {
|
||||||
param([string]$Path)
|
param([string]$Path)
|
||||||
$raw = Get-Content -LiteralPath $Path -Encoding UTF8 -Raw
|
$raw = Get-Content -LiteralPath $Path -Encoding UTF8 -Raw
|
||||||
@@ -542,7 +379,7 @@ function Update-KHDB {
|
|||||||
)
|
)
|
||||||
Start-UpdateTranscript -BasePath $scriptRoot
|
Start-UpdateTranscript -BasePath $scriptRoot
|
||||||
try {
|
try {
|
||||||
$settings = Read-ElysiumSettings
|
$settings = Read-ElysiumSettings -ScriptRoot $scriptRoot
|
||||||
$installPath = Get-InstallationPath $settings
|
$installPath = Get-InstallationPath $settings
|
||||||
Ensure-Directory $installPath
|
Ensure-Directory $installPath
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
##################################################
|
##################################################
|
||||||
## Project: Elysium ##
|
## Project: Elysium ##
|
||||||
## File: Update-LithnetStore.ps1 ##
|
## File: Update-LithnetStore.ps1 ##
|
||||||
## Version: 2.2.0 ##
|
## Version: 2.4.4 ##
|
||||||
## Support: support@cqre.net ##
|
## Support: support@cqre.net ##
|
||||||
##################################################
|
##################################################
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user