Compare commits
18 Commits
1d98b908c6
...
2d90656e5c
| Author | SHA1 | Date | |
|---|---|---|---|
| 2d90656e5c | |||
| 5b761d8d56 | |||
| 80199ad7d6 | |||
| eea0ddf932 | |||
| 2f0ff9085a | |||
| 03ceec9d5e | |||
| 06e7607eff | |||
| 9bef6d50f5 | |||
| d155276366 | |||
| 867fb6427d | |||
| 1440b65b9a | |||
| 5691463bd3 | |||
| 91d6bcb216 | |||
| ec00518952 | |||
| 4740cd3e97 | |||
| 855be8de9c | |||
| ac3f30db1e | |||
| 65e451413e |
+4
-3
@@ -8,13 +8,11 @@
|
||||
##################################################
|
||||
## Project: Elysium ##
|
||||
## File: Bump-Version.ps1 ##
|
||||
## Version: 2.4.4 ##
|
||||
## Version: 2.4.6 ##
|
||||
## Support: support@cqre.net ##
|
||||
##################################################
|
||||
|
||||
#Requires -Version 5.1
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-StrictMode -Version Latest
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
@@ -41,6 +39,9 @@ param(
|
||||
[switch]$SkipChangelog
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-StrictMode -Version Latest
|
||||
|
||||
$scriptRoot = $PSScriptRoot
|
||||
if (-not $scriptRoot) { $scriptRoot = (Get-Location).Path }
|
||||
|
||||
|
||||
@@ -6,6 +6,28 @@ Starting with **v2.2.0**, Elysium uses a **unified project version**. All script
|
||||
|
||||
---
|
||||
|
||||
## [2.4.6] — 2026-07-29
|
||||
|
||||
### Fixed
|
||||
- `Uninstall.ps1` used `Get-Location` (the caller's working directory) instead of `$PSScriptRoot`, so running it from any CWD other than the install folder recursively force-deleted the wrong directory. The accompanying self-delete workaround was also broken (a `-Exclude` that could never match, a deferred `Start-Process` command line that couldn't bind its argument) and has been replaced with a plain recursive delete, since PowerShell holds no open handle on a script it has already read into memory.
|
||||
- `Elysium.ps1` echoed the AES export passphrase to the console (`Read-Host` without `-AsSecureString`) and stored it in plaintext in `HKCU\Environment`. It's now DPAPI-protected via `ConvertFrom-SecureString` and never appears in plaintext at rest; `Extract-NTHashes.ps1` decrypts it back only in memory right before use.
|
||||
- `Test-ReplicationPermissions` resolved the caller's SID/tokenGroups by stripping a `DOMAIN\` prefix from the credential username, so a UPN-formatted credential (`user@domain.tld`) had nothing to strip, the AD lookup threw, and the entire DCSync ACL pre-check was silently skipped. Now handles `DOMAIN\user`, `user@domain.tld`, and bare `sAMAccountName` correctly, and also disposes the `DirectoryEntry`/ADSI objects it creates.
|
||||
- `Elysium.ps1`'s main menu had no `catch` around the switch statement, so any error re-thrown by a sub-script (bad credentials, unreachable DC, etc.) killed the whole orchestrator instead of returning to the menu.
|
||||
- `Extract-NTHashes.ps1`: the temp directory holding plaintext NTLM hashes (before AES encryption) now has its ACL restricted to the current user; a checksum mismatch after upload now deletes the corrupt remote blob instead of leaving it live under its normal name; the plaintext export is now pinned to UTF-8 instead of depending on the PowerShell host; `Protect-FileWithAES`'s output format changed from `ELY1` to `ELY2`, adding an HMAC-SHA256 trailer (encrypt-then-MAC) so a wrong passphrase or tampered/corrupted ciphertext is detected instead of silently decrypting to garbage - **this is a breaking format change for any external decryption tooling**, see the README for the new layout.
|
||||
- `Update-KHDB.ps1`: `Validate-Manifest` hardcoded `shardSize` to exactly `2`, rejecting manifests produced with any other value from `Prepare-KHDBStorage.ps1`'s supported 1-8 range; KHDB backups (`khdb.txt.bak-*`) accumulated forever with no retention policy (now capped at the 5 most recent); `Invoke-DownloadWithRetry` never disposed the `HttpResponseMessage` it got from each shard download.
|
||||
- `Prepare-KHDBStorage.ps1`: `Split-KhdbIntoShards`'s fast path for plain 32-hex-char lines wrote straight through with no deduplication (unlike the slow path's adjacent-duplicate merge); fixing that surfaced a second bug where the valid-entry counter was a plain scalar mutated inside a scriptblock invoked via the call operator (`&`), which runs in its own child scope, so the increments were silently discarded and `TotalEntries`/checkpoint `validEntries`/live progress were all wrong on every run (moved onto the existing `$meta` hashtable, which mutates by reference). Also rejects manifest shard names (`-UploadOnly` mode) that resolve outside the shard root, closing a path-traversal gap for a tampered local `manifest.json`.
|
||||
- `Test-WeakADPasswords.ps1`: report generation piped results through `Out-String` without `-Width`, so a long `SamAccountName` could be truncated by the default formatter width and silently drop its `UPN:` annotation in the report.
|
||||
|
||||
## [2.4.5] — 2026-07-29
|
||||
|
||||
### Fixed
|
||||
- `Test-ReplicationPermissions` now detects explicit **Deny** ACEs on the replication extended rights, not just missing Allow grants. Previously the pre-flight check only scanned `Allow` ACEs, so an explicit Deny (common in hardening baselines that Deny a broad group like `Everyone`/`Domain Users` the replication rights and Allow only named DCSync accounts) was invisible to the check: it reported "verified" while `Get-ADReplAccount` still failed with "Replication access was denied". The check now flags exactly which right is blocked and by which identity's Deny ACE.
|
||||
|
||||
### Changed
|
||||
- README *Common errors* section expanded with a dedicated troubleshooting flow for "pre-flight passed but DCSync still denied" (RODC target, unconverged ACL replication, cross-domain group scope, and how to get a definitive answer via Event ID 4662 auditing).
|
||||
|
||||
---
|
||||
|
||||
## [2.4.4] — 2026-06-15
|
||||
|
||||
### Fixed
|
||||
|
||||
+50
-11
@@ -1,4 +1,4 @@
|
||||
$script:ElysiumVersion = '2.4.4'
|
||||
$script:ElysiumVersion = '2.4.6'
|
||||
|
||||
function Invoke-RestartWithExecutable {
|
||||
param(
|
||||
@@ -348,14 +348,28 @@ function Test-ReplicationPermissions {
|
||||
|
||||
$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
|
||||
# Get-ADUser -Identity accepts a DN, GUID, SID, or sAMAccountName - but NOT a UPN. A
|
||||
# UPN-formatted credential (user@domain.tld) has no backslash, so naively stripping a
|
||||
# 'DOMAIN\' prefix left the full UPN in place, -Identity threw, and this whole pre-check
|
||||
# was silently skipped (caught below) regardless of how the username was typed.
|
||||
$rawUserName = $Credential.UserName
|
||||
if ($rawUserName -match '^[^\\]+\\(.+)$') {
|
||||
$adUser = Get-ADUser -Identity $Matches[1] -Server $Server -Credential $Credential `
|
||||
-Properties SID, DistinguishedName, adminCount -ErrorAction Stop
|
||||
} elseif ($rawUserName -match '@') {
|
||||
$adUser = Get-ADUser -Filter "UserPrincipalName -eq '$rawUserName'" -Server $Server -Credential $Credential `
|
||||
-Properties SID, DistinguishedName, adminCount -ErrorAction Stop | Select-Object -First 1
|
||||
if (-not $adUser) { throw "No AD user found with UserPrincipalName '$rawUserName'." }
|
||||
} else {
|
||||
$adUser = Get-ADUser -Identity $rawUserName -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 `
|
||||
# including nested group memberships - more reliable than walking MemberOf recursively.
|
||||
# Look up by DistinguishedName (unambiguous) rather than re-deriving the username format.
|
||||
$adUserWithTokenGroups = Get-ADUser -Identity $adUser.DistinguishedName -Server $Server -Credential $Credential `
|
||||
-Properties tokenGroups -ErrorAction Stop
|
||||
foreach ($sidBytes in $adUserWithTokenGroups.tokenGroups) {
|
||||
$sid = New-Object System.Security.Principal.SecurityIdentifier(@([byte[]]$sidBytes), 0)
|
||||
@@ -386,6 +400,7 @@ function Test-ReplicationPermissions {
|
||||
$rightsToCheck = $ncEntry.Value
|
||||
|
||||
$acl = $null
|
||||
$de = $null
|
||||
try {
|
||||
$de = New-Object System.DirectoryServices.DirectoryEntry(
|
||||
"LDAP://$Server/$ncDN",
|
||||
@@ -397,14 +412,16 @@ function Test-ReplicationPermissions {
|
||||
} catch {
|
||||
Write-Warning ("Could not read ACL on '$ncDN' for replication permission pre-check: {0}. Skipping." -f $_.Exception.Message)
|
||||
continue
|
||||
} finally {
|
||||
if ($de) { $de.Dispose() }
|
||||
}
|
||||
|
||||
foreach ($rightName in $rightsToCheck.Keys) {
|
||||
$guid = $rightsToCheck[$rightName]
|
||||
$granted = $false
|
||||
$aceExistsForGuid = $false
|
||||
$denyIdentity = $null
|
||||
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
|
||||
@@ -415,10 +432,22 @@ function Test-ReplicationPermissions {
|
||||
-or ($hasExtended -and $ace.ObjectType -eq [guid]::Empty) `
|
||||
-or ($hasExtended -and $ace.ObjectType -eq $guid)
|
||||
if (-not $isMatch) { continue }
|
||||
if (-not $callerSids.Contains($ace.IdentityReference.Value)) { continue }
|
||||
|
||||
if ($ace.AccessControlType -eq [System.Security.AccessControl.AccessControlType]::Deny) {
|
||||
# Explicit Deny ACEs are evaluated before Allow ACEs in a canonical ACL and win
|
||||
# regardless of any Allow found elsewhere. A check that only scans Allow ACEs would
|
||||
# falsely report the right as granted while the actual DRS call is still denied.
|
||||
$denyIdentity = $ace.IdentityReference.Value
|
||||
continue
|
||||
}
|
||||
|
||||
if ($ace.ObjectType -eq $guid) { $aceExistsForGuid = $true }
|
||||
if ($callerSids.Contains($ace.IdentityReference.Value)) { $granted = $true; break }
|
||||
$granted = $true
|
||||
}
|
||||
if (-not $granted) {
|
||||
if ($denyIdentity) {
|
||||
$allMissingLines += "[on $ncDN] $rightName (DENIED by explicit Deny ACE for '$denyIdentity' - this overrides any Allow grant)"
|
||||
} elseif (-not $granted) {
|
||||
$hint = if ($aceExistsForGuid) {
|
||||
' (ACE exists but not assigned to this account or any of its groups)'
|
||||
} else {
|
||||
@@ -436,8 +465,15 @@ function Test-ReplicationPermissions {
|
||||
" 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)
|
||||
$denyNote = ''
|
||||
if ($allMissingLines | Where-Object { $_ -match 'DENIED by explicit Deny ACE' }) {
|
||||
$denyNote = ("`n`nNOTE: at least one right is blocked by an explicit Deny ACE, not a missing grant." +
|
||||
" Find and remove/scope it: Advanced Security on the NC object > look for a Deny entry" +
|
||||
" covering 'Replicating Directory Changes*' that matches this account or one of its groups" +
|
||||
" (common with hardening baselines that Deny a broad group like Everyone/Domain Users).")
|
||||
}
|
||||
throw ("Account '{0}' failed replication permission check:`n - {1}{2}{3}" -f `
|
||||
$Credential.UserName, ($allMissingLines -join "`n - "), $schemaNote, $denyNote)
|
||||
}
|
||||
|
||||
Write-Host ("[+] Replication permissions verified for '{0}' on domain NC and schema NC." -f $Credential.UserName)
|
||||
@@ -448,6 +484,7 @@ function Test-DCClockSkew {
|
||||
[Parameter(Mandatory)][string]$Server,
|
||||
[Parameter(Mandatory)][System.Management.Automation.PSCredential]$Credential
|
||||
)
|
||||
$rootDse = $null
|
||||
try {
|
||||
$rootDse = New-Object System.DirectoryServices.DirectoryEntry(
|
||||
"LDAP://$Server/RootDSE",
|
||||
@@ -469,5 +506,7 @@ function Test-DCClockSkew {
|
||||
}
|
||||
} catch {
|
||||
Write-Warning ("Could not check clock skew against '{0}': {1}" -f $Server, $_.Exception.Message)
|
||||
} finally {
|
||||
if ($rootDse) { $rootDse.Dispose() }
|
||||
}
|
||||
}
|
||||
|
||||
+23
-10
@@ -7,7 +7,7 @@
|
||||
##################################################
|
||||
## Project: Elysium ##
|
||||
## File: Elysium.ps1 ##
|
||||
## Version: 2.4.4 ##
|
||||
## Version: 2.4.6 ##
|
||||
## Support: support@cqre.net ##
|
||||
##################################################
|
||||
|
||||
@@ -39,17 +39,26 @@ if (-Not (Test-Path $settingsFilePath)) {
|
||||
Write-Host "ElysiumSettings.txt found."
|
||||
}
|
||||
|
||||
# Attempt to retrieve the passphrase from the environment variable
|
||||
$passphrase = [System.Environment]::GetEnvironmentVariable("ELYSIUM_PASSPHRASE", [System.EnvironmentVariableTarget]::User)
|
||||
# The passphrase is persisted DPAPI-protected (current user + machine) via ConvertFrom-SecureString,
|
||||
# never in plaintext, so it's only prompted for once per user/machine and never echoed to the console.
|
||||
$storedPassphrase = [System.Environment]::GetEnvironmentVariable("ELYSIUM_PASSPHRASE", [System.EnvironmentVariableTarget]::User)
|
||||
|
||||
if ([string]::IsNullOrEmpty($passphrase)) {
|
||||
$havePassphrase = $false
|
||||
if (-not [string]::IsNullOrEmpty($storedPassphrase)) {
|
||||
try {
|
||||
[void](ConvertTo-SecureString -String $storedPassphrase -ErrorAction Stop)
|
||||
$havePassphrase = $true
|
||||
Write-Host "Passphrase found in environment variables."
|
||||
} catch {
|
||||
Write-Warning "Stored passphrase is not in the expected protected format (leftover from an older Elysium version?). Re-enter it."
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $havePassphrase) {
|
||||
Write-Host "No passphrase found in environment variables."
|
||||
$passphrase = Read-Host "Please enter your passphrase."
|
||||
# Here you could choose to set the environment variable or simply use the passphrase for the current session
|
||||
[System.Environment]::SetEnvironmentVariable("ELYSIUM_PASSPHRASE", $passphrase, [System.EnvironmentVariableTarget]::User)
|
||||
Write-Host "Passphrase stored as environment variable 'ELYSIUM_PASSPHRASE'."
|
||||
} else {
|
||||
Write-Host "Passphrase found in environment variables."
|
||||
$securePassphrase = Read-Host "Please enter your passphrase" -AsSecureString
|
||||
[System.Environment]::SetEnvironmentVariable("ELYSIUM_PASSPHRASE", (ConvertFrom-SecureString -SecureString $securePassphrase), [System.EnvironmentVariableTarget]::User)
|
||||
Write-Host "Passphrase stored (DPAPI-protected) as environment variable 'ELYSIUM_PASSPHRASE'."
|
||||
}
|
||||
|
||||
function Start-OrchestratorTranscript {
|
||||
@@ -100,6 +109,7 @@ try {
|
||||
do {
|
||||
Show-Menu
|
||||
$userSelection = Read-Host "Please make a selection"
|
||||
try {
|
||||
switch ($userSelection) {
|
||||
'1' {
|
||||
Write-Host "Downloading KHDB..."
|
||||
@@ -140,6 +150,9 @@ do {
|
||||
Write-Host "Invalid selection, please try again."
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
Write-Error ("An error occurred while running the selected option: {0}" -f $_.Exception.Message)
|
||||
}
|
||||
pause
|
||||
} while ($userSelection -ne '6')
|
||||
} finally {
|
||||
|
||||
+88
-26
@@ -7,7 +7,7 @@
|
||||
##################################################
|
||||
## Project: Elysium ##
|
||||
## File: Extract-NTHashes.ps1 ##
|
||||
## Version: 2.4.4 ##
|
||||
## Version: 2.4.6 ##
|
||||
## Support: support@cqre.net ##
|
||||
##################################################
|
||||
|
||||
@@ -23,6 +23,14 @@ This script will connect to selected domain (defined in ElysiumSettings.txt) usi
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-StrictMode -Version Latest
|
||||
|
||||
# Ensure consistent UTF-8 output for files across PS5.1/PS7 (Out-File defaults to UTF-16LE on
|
||||
# Desktop edition and UTF-8 on Core, so the plaintext hash export's encoding would otherwise
|
||||
# depend purely on which PowerShell host happens to run it).
|
||||
try {
|
||||
$PSDefaultParameterValues['Out-File:Encoding'] = 'utf8'
|
||||
$OutputEncoding = New-Object System.Text.UTF8Encoding($false)
|
||||
} catch { }
|
||||
|
||||
$scriptRoot = $PSScriptRoot
|
||||
|
||||
[string]$commonHelper = Join-Path -Path $PSScriptRoot -ChildPath 'Elysium.Common.ps1'
|
||||
@@ -93,6 +101,23 @@ function Invoke-S3GetToFile([string]$endpointUrl, [string]$bucket, [string]$key,
|
||||
} finally { if ($req) { $req.Dispose() }; $client.Dispose() }
|
||||
}
|
||||
|
||||
function Invoke-S3DeleteFile([string]$endpointUrl, [string]$bucket, [string]$key, [string]$region, [string]$ak, [string]$sk, [bool]$forcePathStyle) {
|
||||
$uri = BuildS3Uri -endpointUrl $endpointUrl -bucket $bucket -key $key -forcePathStyle $forcePathStyle
|
||||
$payloadHash = (Get-HashHex (Get-Bytes ''))
|
||||
Add-Type -AssemblyName System.Net.Http -ErrorAction SilentlyContinue
|
||||
$client = [System.Net.Http.HttpClient]::new()
|
||||
try {
|
||||
$req = [System.Net.Http.HttpRequestMessage]::new([System.Net.Http.HttpMethod]::Delete, $uri)
|
||||
$hdrs = BuildAuthHeaders -method 'DELETE' -uri $uri -region $region -accessKey $ak -secretKey $sk -payloadHash $payloadHash
|
||||
$req.Headers.TryAddWithoutValidation('x-amz-date', $hdrs['x-amz-date']) | Out-Null
|
||||
$req.Headers.TryAddWithoutValidation('Authorization', $hdrs['Authorization']) | Out-Null
|
||||
$req.Headers.TryAddWithoutValidation('x-amz-content-sha256', $hdrs['x-amz-content-sha256']) | Out-Null
|
||||
$resp = $client.SendAsync($req).Result
|
||||
# S3 DELETE is idempotent and returns 204 even if the key never existed; anything else is a real failure.
|
||||
if (-not $resp.IsSuccessStatusCode) { throw "S3 DELETE failed: $([int]$resp.StatusCode) $($resp.ReasonPhrase)" }
|
||||
} finally { if ($req) { $req.Dispose() }; $client.Dispose() }
|
||||
}
|
||||
|
||||
function Protect-FileWithAES {
|
||||
param (
|
||||
[Parameter(Mandatory = $true)]
|
||||
@@ -109,8 +134,15 @@ function Protect-FileWithAES {
|
||||
$salt = New-Object byte[] 16
|
||||
$rng.GetBytes($salt)
|
||||
|
||||
# Derive two independent keys from one PBKDF2 byte stream: the first 32 bytes for AES-256, the
|
||||
# next 32 for HMAC-SHA256 (Rfc2898DeriveBytes.GetBytes returns a continuous stream across
|
||||
# calls on the same instance, so these two ranges never overlap). CBC alone gives no integrity
|
||||
# check - tampered or corrupted ciphertext just decrypts to garbage (or throws an unhelpful
|
||||
# padding exception) instead of being detected. Encrypt-then-MAC over magic+salt+iv+ciphertext
|
||||
# (format 'ELY2') catches both. Older 'ELY1' files this script produced have no MAC.
|
||||
$kdf = New-Object System.Security.Cryptography.Rfc2898DeriveBytes($Passphrase, $salt, 100000, [System.Security.Cryptography.HashAlgorithmName]::SHA256)
|
||||
$key = $kdf.GetBytes(32)
|
||||
$aesKey = $kdf.GetBytes(32)
|
||||
$hmacKey = $kdf.GetBytes(32)
|
||||
|
||||
$aes = [System.Security.Cryptography.Aes]::Create()
|
||||
$aes.KeySize = 256
|
||||
@@ -118,34 +150,24 @@ function Protect-FileWithAES {
|
||||
$aes.Mode = [System.Security.Cryptography.CipherMode]::CBC
|
||||
$aes.Padding = [System.Security.Cryptography.PaddingMode]::PKCS7
|
||||
$aes.GenerateIV()
|
||||
|
||||
$iv = $aes.IV
|
||||
$encryptor = $aes.CreateEncryptor($key, $iv)
|
||||
|
||||
$fileStream = [System.IO.File]::Open($InputFile, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read)
|
||||
$outFileStream = [System.IO.File]::Create($OutputFile)
|
||||
|
||||
$encryptor = $aes.CreateEncryptor($aesKey, $iv)
|
||||
$hmac = [System.Security.Cryptography.HMACSHA256]::new($hmacKey)
|
||||
try {
|
||||
$magic = [System.Text.Encoding]::ASCII.GetBytes('ELY1')
|
||||
$outFileStream.Write($magic, 0, $magic.Length)
|
||||
$outFileStream.Write($salt, 0, $salt.Length)
|
||||
$outFileStream.Write($iv, 0, $iv.Length)
|
||||
$plainBytes = [System.IO.File]::ReadAllBytes($InputFile)
|
||||
$cipherBytes = $encryptor.TransformFinalBlock($plainBytes, 0, $plainBytes.Length)
|
||||
|
||||
$cryptoStream = New-Object System.Security.Cryptography.CryptoStream($outFileStream, $encryptor, [System.Security.Cryptography.CryptoStreamMode]::Write)
|
||||
try {
|
||||
$buffer = New-Object Byte[] 8192
|
||||
while (($read = $fileStream.Read($buffer, 0, $buffer.Length)) -gt 0) {
|
||||
$cryptoStream.Write($buffer, 0, $read)
|
||||
}
|
||||
} finally {
|
||||
$cryptoStream.FlushFinalBlock()
|
||||
$cryptoStream.Close()
|
||||
}
|
||||
$magic = [System.Text.Encoding]::ASCII.GetBytes('ELY2')
|
||||
$header = $magic + $salt + $iv
|
||||
$mac = $hmac.ComputeHash($header + $cipherBytes)
|
||||
|
||||
[System.IO.File]::WriteAllBytes($OutputFile, ($header + $cipherBytes + $mac))
|
||||
} finally {
|
||||
$outFileStream.Close(); $fileStream.Close(); $aes.Dispose(); $rng.Dispose(); $kdf.Dispose()
|
||||
$encryptor.Dispose(); $hmac.Dispose(); $aes.Dispose(); $rng.Dispose(); $kdf.Dispose()
|
||||
}
|
||||
|
||||
Write-Host "File has been encrypted (PBKDF2+AES-256-CBC): $OutputFile"
|
||||
Write-Host "File has been encrypted (PBKDF2+AES-256-CBC+HMAC-SHA256): $OutputFile"
|
||||
}
|
||||
|
||||
function Get-FileChecksum {
|
||||
@@ -190,9 +212,15 @@ try {
|
||||
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.' }
|
||||
# Retrieve the DPAPI-protected passphrase from a user environment variable (see Elysium.ps1)
|
||||
$protectedPassphrase = [System.Environment]::GetEnvironmentVariable("ELYSIUM_PASSPHRASE", [System.EnvironmentVariableTarget]::User)
|
||||
if ([string]::IsNullOrWhiteSpace($protectedPassphrase)) { throw 'Passphrase not found in ELYSIUM_PASSPHRASE environment variable. Run Elysium.ps1 once to set it.' }
|
||||
try {
|
||||
$securePassphrase = ConvertTo-SecureString -String $protectedPassphrase -ErrorAction Stop
|
||||
} catch {
|
||||
throw "ELYSIUM_PASSPHRASE is not in the expected DPAPI-protected format (leftover from an older Elysium version?). Re-run Elysium.ps1 to re-enter it."
|
||||
}
|
||||
$passphrase = [System.Net.NetworkCredential]::new('', $securePassphrase).Password
|
||||
|
||||
$timestamp = Get-Date -Format "yyyyMMdd-HHmmss"
|
||||
|
||||
@@ -250,6 +278,21 @@ try {
|
||||
# never written to the installation directory and are always cleaned up.
|
||||
$tmpDir = New-Item -ItemType Directory -Path ([System.IO.Path]::Combine(
|
||||
[System.IO.Path]::GetTempPath(), "elysium-extract-" + [System.Guid]::NewGuid())) -Force
|
||||
try {
|
||||
# Plaintext NTLM hashes land in this directory before AES protection is applied below.
|
||||
# Strip inherited ACEs (e.g. a broad "Users" grant on the parent Temp folder) so only the
|
||||
# current user can read it while the finally block's cleanup hasn't run yet.
|
||||
$dirAcl = $tmpDir.GetAccessControl()
|
||||
$dirAcl.SetAccessRuleProtection($true, $false)
|
||||
$currentUserRule = New-Object System.Security.AccessControl.FileSystemAccessRule(
|
||||
[System.Security.Principal.WindowsIdentity]::GetCurrent().User,
|
||||
[System.Security.AccessControl.FileSystemRights]::FullControl,
|
||||
'ContainerInherit,ObjectInherit', 'None', 'Allow')
|
||||
$dirAcl.AddAccessRule($currentUserRule)
|
||||
$tmpDir.SetAccessControl($dirAcl)
|
||||
} catch {
|
||||
Write-Warning "Could not restrict ACL on temporary directory '$($tmpDir.FullName)': $($_.Exception.Message)"
|
||||
}
|
||||
$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"
|
||||
@@ -334,6 +377,25 @@ try {
|
||||
if ($tempDownloadPath -and (Test-Path $tempDownloadPath)) {
|
||||
Remove-Item -Path $tempDownloadPath -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
# A checksum mismatch means the blob already sitting in remote storage under $blobName
|
||||
# is corrupt/incomplete. Leaving it live under its normal name would let a downstream
|
||||
# consumer silently fetch bad data, so remove it rather than only warning locally.
|
||||
try {
|
||||
if ($storageProvider -ieq 'S3') {
|
||||
if ($usedAwsTools -and $s3Client) {
|
||||
$delReq = New-Object Amazon.S3.Model.DeleteObjectRequest -Property @{ BucketName = $s3BucketName; Key = $blobName }
|
||||
$null = $s3Client.DeleteObject($delReq)
|
||||
} else {
|
||||
Invoke-S3DeleteFile -endpointUrl $s3EndpointUrl -bucket $s3BucketName -key $blobName -region $s3Region -ak $s3AccessKeyId -sk $s3SecretAccessKey -forcePathStyle:$s3ForcePathStyle
|
||||
}
|
||||
} else {
|
||||
Remove-AzStorageBlob -Blob $blobName -Container $containerName -Context $storageContext -Force -ErrorAction Stop
|
||||
}
|
||||
Write-Warning "Removed the mismatched blob '$blobName' from remote storage."
|
||||
} catch {
|
||||
Write-Warning "Could not remove the mismatched blob '$blobName' from remote storage - remove it manually: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
# Always delete plaintext hashes and compressed archive regardless of outcome.
|
||||
|
||||
+42
-14
@@ -7,7 +7,7 @@
|
||||
##################################################
|
||||
## Project: Elysium ##
|
||||
## File: Prepare-KHDBStorage.ps1 ##
|
||||
## Version: 2.4.4 ##
|
||||
## Version: 2.4.6 ##
|
||||
## Support: support@cqre.net ##
|
||||
##################################################
|
||||
|
||||
@@ -241,14 +241,19 @@ function Split-KhdbIntoShards {
|
||||
|
||||
$shardStates = @{}
|
||||
$stats = @{}
|
||||
$total = 0L
|
||||
$resumeFilePosition = 0L
|
||||
|
||||
# ValidEntries lives on $meta (a hashtable, i.e. reference type) rather than as its own scalar
|
||||
# variable because $processHashLine below is invoked via the call operator (&), which runs in
|
||||
# its own child scope - a bare scalar '$total++' inside it would silently increment a local
|
||||
# shadow copy and never update the caller's variable. Mutating a property on a shared
|
||||
# hashtable/object works fine across that scope boundary.
|
||||
$meta = @{
|
||||
TotalLines = 0L
|
||||
InvalidLines = 0L
|
||||
SkippedLines = 0L
|
||||
LegacyLines = 0L
|
||||
ValidEntries = 0L
|
||||
InvalidSamples = New-Object System.Collections.Generic.List[string]
|
||||
}
|
||||
|
||||
@@ -271,7 +276,7 @@ function Split-KhdbIntoShards {
|
||||
if ($ResumeState.totalLines) { $meta.TotalLines = [long]$ResumeState.totalLines }
|
||||
if ($ResumeState.invalidLines) { $meta.InvalidLines = [long]$ResumeState.invalidLines }
|
||||
if ($ResumeState.skippedLines) { $meta.SkippedLines = [long]$ResumeState.skippedLines }
|
||||
if ($ResumeState.validEntries) { $total = [long]$ResumeState.validEntries }
|
||||
if ($ResumeState.validEntries) { $meta.ValidEntries = [long]$ResumeState.validEntries }
|
||||
if ($ResumeState.filePosition) { $resumeFilePosition = [long]$ResumeState.filePosition }
|
||||
}
|
||||
$plainReader = $null
|
||||
@@ -371,7 +376,7 @@ function Split-KhdbIntoShards {
|
||||
totalLines = [long]$meta.TotalLines
|
||||
invalidLines = [long]$meta.InvalidLines
|
||||
skippedLines = [long]$meta.SkippedLines
|
||||
validEntries = [long]$total
|
||||
validEntries = [long]$meta.ValidEntries
|
||||
shardStates = @()
|
||||
}
|
||||
foreach ($entry in ($shardStates.GetEnumerator() | Sort-Object Key)) {
|
||||
@@ -403,7 +408,7 @@ function Split-KhdbIntoShards {
|
||||
if ([string]::IsNullOrWhiteSpace($statusContext)) {
|
||||
$statusContext = if ($currentSource) { Split-Path -Leaf $currentSource } else { 'input' }
|
||||
}
|
||||
$status = "Processed {0:N0} hashes (+{1:N0} invalid, {2:N0} skipped, {3:N0} lines) [{4}]" -f $total, $meta.InvalidLines, $meta.SkippedLines, $meta.TotalLines, $statusContext
|
||||
$status = "Processed {0:N0} hashes (+{1:N0} invalid, {2:N0} skipped, {3:N0} lines) [{4}]" -f $meta.ValidEntries, $meta.InvalidLines, $meta.SkippedLines, $meta.TotalLines, $statusContext
|
||||
Write-Progress -Activity $ProgressActivity -Status $status -PercentComplete 0
|
||||
if ($EnableCheckpoint -and $plainReader) {
|
||||
$checkpointPosition = if ($plainBaseStream) { $plainBaseStream.Position } else { $plainReader.BaseStream.Position }
|
||||
@@ -427,12 +432,26 @@ function Split-KhdbIntoShards {
|
||||
return
|
||||
}
|
||||
|
||||
# Fast path for valid 32-char hex lines
|
||||
# Fast path for valid 32-char hex lines. Routes through the same pending-line dedup as the
|
||||
# slow path below (adjacent identical hashes are dropped) instead of writing straight
|
||||
# through - the source is expected to be pre-sorted, so only *adjacent* duplicates are
|
||||
# caught either way, but the fast path used to skip this entirely and write every
|
||||
# duplicate straight to the shard.
|
||||
if ($rawLine.Length -eq 32 -and $rawLine -match '^[0-9A-Fa-f]{32}$') {
|
||||
$prefixKey = $rawLine.Substring(0, $PrefixLength).ToLowerInvariant()
|
||||
$shardStates[$prefixKey].Writer.WriteLine($rawLine.ToUpperInvariant())
|
||||
$normalizedHash = $rawLine.ToUpperInvariant()
|
||||
$prefixKey = $normalizedHash.Substring(0, $PrefixLength).ToLowerInvariant()
|
||||
$state = $shardStates[$prefixKey]
|
||||
if ($state.PendingHash -ne $normalizedHash) {
|
||||
if ($state.PendingLine) {
|
||||
$state.Writer.WriteLine($state.PendingLine)
|
||||
$state.Count++
|
||||
$meta.ValidEntries++
|
||||
}
|
||||
$state.PendingLine = $normalizedHash
|
||||
$state.PendingHash = $normalizedHash
|
||||
$state.PendingCount = 0
|
||||
}
|
||||
$meta.TotalLines++
|
||||
$total++
|
||||
return
|
||||
}
|
||||
|
||||
@@ -517,7 +536,7 @@ function Split-KhdbIntoShards {
|
||||
if ($state.PendingLine) {
|
||||
$state.Writer.WriteLine($state.PendingLine)
|
||||
$state.Count++
|
||||
$total++
|
||||
$meta.ValidEntries++
|
||||
}
|
||||
$state.PendingLine = $normalizedLine
|
||||
$state.PendingHash = $normalizedHash
|
||||
@@ -651,7 +670,7 @@ function Split-KhdbIntoShards {
|
||||
if ($state.PendingLine) {
|
||||
$state.Writer.WriteLine($state.PendingLine)
|
||||
$state.Count++
|
||||
$total++
|
||||
$meta.ValidEntries++
|
||||
$state.PendingLine = $null
|
||||
}
|
||||
$state.Writer.Dispose()
|
||||
@@ -661,13 +680,13 @@ function Split-KhdbIntoShards {
|
||||
}
|
||||
}
|
||||
|
||||
if ($total -eq 0) { throw 'Source did not contain any valid hashes after processing.' }
|
||||
if ($meta.ValidEntries -eq 0) { throw 'Source did not contain any valid hashes after processing.' }
|
||||
if ($ShowProgress) {
|
||||
$status = "Processed {0:N0} hashes (+{1:N0} invalid, {2:N0} skipped, {3:N0} lines)" -f $total, $meta.InvalidLines, $meta.SkippedLines, $meta.TotalLines
|
||||
$status = "Processed {0:N0} hashes (+{1:N0} invalid, {2:N0} skipped, {3:N0} lines)" -f $meta.ValidEntries, $meta.InvalidLines, $meta.SkippedLines, $meta.TotalLines
|
||||
Write-Progress -Activity $ProgressActivity -Status $status -Completed
|
||||
}
|
||||
return [pscustomobject]@{
|
||||
TotalEntries = [long]$total
|
||||
TotalEntries = [long]$meta.ValidEntries
|
||||
ShardStats = $stats
|
||||
TotalLines = [long]$meta.TotalLines
|
||||
InvalidLines = [long]$meta.InvalidLines
|
||||
@@ -873,12 +892,21 @@ if ($UploadOnly) {
|
||||
|
||||
$manifestHash = (Get-FileHash -Path $manifestPath -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
|
||||
$resolvedLocalShardRoot = [System.IO.Path]::GetFullPath($localShardRoot).TrimEnd([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar) + [System.IO.Path]::DirectorySeparatorChar
|
||||
|
||||
$manifestShards = @()
|
||||
$totalSizeBytes = 0L
|
||||
foreach ($entry in ($manifestObject.shards | Sort-Object name)) {
|
||||
$name = [string]$entry.name
|
||||
if ([string]::IsNullOrWhiteSpace($name)) { continue }
|
||||
$localPath = Join-Path -Path $localShardRoot -ChildPath $name
|
||||
# Manifest shard names are attacker-controllable if manifest.json was tampered with (requires
|
||||
# prior local write access to the shard directory). Reject anything that resolves outside the
|
||||
# shard root instead of trusting Join-Path to keep '..'/rooted paths contained.
|
||||
$resolvedLocalPath = [System.IO.Path]::GetFullPath($localPath)
|
||||
if (-not $resolvedLocalPath.StartsWith($resolvedLocalShardRoot, [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||
throw "Manifest shard name '$name' resolves outside the shard directory '$localShardRoot'."
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $localPath)) {
|
||||
throw "Shard file '$name' listed in manifest was not found under '$localShardRoot'."
|
||||
}
|
||||
|
||||
@@ -88,8 +88,16 @@ Keep the service account disabled and only activate it for scheduled tests.
|
||||
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 ...`:
|
||||
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.
|
||||
- `Replication access was denied` (from `Get-ADReplAccount`):
|
||||
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.
|
||||
- `Account '<user>' failed replication permission check ... (DENIED by explicit Deny ACE for '<sid>')`:
|
||||
The pre-flight check (v2.4.5+) also scans for explicit **Deny** ACEs on the replication extended rights, not just missing Allow grants. A Deny ACE — commonly added by hardening baselines that Deny a broad group (`Everyone`, `Domain Users`, `Authenticated Users`) the replication rights and Allow only named DCSync accounts — wins over any Allow, even one granted directly to this account. Open *Advanced Security* on the flagged NC object, find the Deny entry that matches this account or one of its groups, and either remove it or exclude the service account/its group from it.
|
||||
- `Replication access was denied` (from `Get-ADReplAccount`) **after the pre-flight check reported success**:
|
||||
This means the ACL looks correct from LDAP but the live DRS (`GetNCChanges`) call still denies access. Known causes, roughly in likelihood order:
|
||||
1. **Target DC is an RODC.** Read-only domain controllers enforce the Password Replication Policy and will refuse to originate a full DCSync of secrets for accounts outside their allowed replication list, regardless of ACL grants. Point `ElysiumSettings.txt` at a writable DC instead.
|
||||
2. **ACL change hasn't converged yet.** If the rights were just delegated on a different DC than the one configured for the test, wait for AD replication to catch up (or force it with `repadmin /syncall`) before retrying.
|
||||
3. **Explicit Deny ACE not caught by an older script version.** Update to v2.4.5+ so the pre-flight check surfaces it (see above) instead of only discovering it at DCSync time.
|
||||
4. **Account is in a cross-domain group** whose scope isn't visible in `tokenGroups` from the DC being queried (for example, a domain-local group in a different domain). Re-delegate directly to the account or to a universal group instead.
|
||||
|
||||
For a definitive answer straight from the DC: enable "Audit Directory Service Access" and add a SACL for `Replicating Directory Changes*` on the domain/schema NC, then check the DC's Security event log for Event ID 4662 on the next failed run — it names the exact object and right that were denied.
|
||||
- `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.
|
||||
|
||||
@@ -100,6 +108,8 @@ If you want to know the script was executed without collecting telemetry, set a
|
||||
Run script Elysium.ps1 as an administrator and choose option 3 (Extract and Send Hashes).
|
||||
Domains are listed in configuration order, after which the script prompts for the replication-capable account password. With valid credentials, it extracts current NTLM hashes (no history) for active accounts, compresses the results, encrypts them with the configured passphrase, and uploads the payload to the configured storage (Azure Blob or S3-compatible). A checksum-verified round-trip download confirms the upload before local artifacts are removed.
|
||||
|
||||
**Encrypted export format (v2.4.5+, magic `ELY2`):** `4-byte magic 'ELY2' | 16-byte PBKDF2 salt | 16-byte AES IV | AES-256-CBC ciphertext | 32-byte HMAC-SHA256`. Both keys are derived from the configured passphrase via one PBKDF2-SHA256 (100,000 iterations) byte stream: the first 32 bytes are the AES key, the next 32 are the HMAC key. The HMAC covers `magic | salt | iv | ciphertext` (encrypt-then-MAC) so a decrypt tool must verify it *before* decrypting - CBC alone doesn't detect a wrong passphrase or corrupted/tampered ciphertext, it just produces garbage or an unhelpful padding exception. This is a breaking change from the older `ELY1` format (no HMAC trailer) produced before v2.4.5; any external decryption tooling on the air-gapped cracking machine needs updating to match.
|
||||
|
||||
### Update Lithnet Password Protection store
|
||||
Run script Elysium.ps1 as an administrator and choose option 5 (Update Lithnet Password Protection Store).
|
||||
Configure the target folder via `LithnetStorePath` in `ElysiumSettings.txt` (the location created with `Open-Store`). The script automatically imports the `khdb.txt` file unless you override/add additional NTLM hash lists in `LithnetHashSources` (comma or semicolon separated). You can also populate plaintext password lists (`LithnetPlaintextSources`) and banned-word files (`LithnetBannedWordSources`), or enable `LithnetSyncHibp=true` to seed the store directly from the Have I Been Pwned API (using `Sync-HashesFromHibp`). Behind the scenes the helper loads the `LithnetPasswordProtection` module, opens the store, runs [`Import-CompromisedPasswordHashes`](https://docs.lithnet.io/password-protection/advanced-help/powershell-reference/import-compromisedpasswordhashes)/`Import-CompromisedPasswords`/`Import-BannedWords` for each configured file, and then closes the store.
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
##################################################
|
||||
## Project: Elysium ##
|
||||
## File: Test-WeakADPasswords.ps1 ##
|
||||
## Version: 2.4.4 ##
|
||||
## Version: 2.4.6 ##
|
||||
## Support: support@cqre.net ##
|
||||
##################################################
|
||||
|
||||
@@ -725,7 +725,10 @@ function Test-WeakADPasswords {
|
||||
}
|
||||
|
||||
Write-Verbose "Generating report at $reportPath"
|
||||
$reportContent = @($header, ($testResults | Out-String).Trim(), $footer) -join "`r`n"
|
||||
# -Width prevents PowerShell's default table formatter from truncating long columns (e.g. a
|
||||
# long SamAccountName) at console/default width, which would otherwise silently break the
|
||||
# first-token re-parse below used to attach "UPN:" lines to dictionary hits.
|
||||
$reportContent = @($header, ($testResults | Out-String -Width 4096).Trim(), $footer) -join "`r`n"
|
||||
|
||||
$lines = $reportContent -split "`r`n"
|
||||
$newReportContent = @()
|
||||
|
||||
+7
-13
@@ -7,7 +7,7 @@
|
||||
##################################################
|
||||
## Project: Elysium ##
|
||||
## File: Uninstall.ps1 ##
|
||||
## Version: 2.4.4 ##
|
||||
## Version: 2.4.6 ##
|
||||
## Support: support@cqre.net ##
|
||||
##################################################
|
||||
|
||||
@@ -41,26 +41,20 @@ function Start-UninstallTranscript {
|
||||
function Stop-UninstallTranscript { try { Stop-Transcript | Out-Null } catch {} }
|
||||
|
||||
function Uninstall-Elysium {
|
||||
$ElysiumPath = Get-Location
|
||||
$ElysiumPath = $PSScriptRoot
|
||||
|
||||
Write-Host "Uninstalling Elysium tool from $ElysiumPath..."
|
||||
|
||||
# Check if the Elysium directory exists
|
||||
if (Test-Path $ElysiumPath) {
|
||||
# Schedule the script file for deletion
|
||||
$scriptPath = $MyInvocation.MyCommand.Path
|
||||
$deleteScript = { param($path) Remove-Item -Path $path -Force }
|
||||
Start-Sleep -Seconds 3 # Delay to ensure the script finishes
|
||||
Start-Process -FilePath "powershell.exe" -ArgumentList "-Command", $deleteScript, "-ArgumentList", $scriptPath -WindowStyle Hidden
|
||||
|
||||
# Remove the Elysium directory and all its contents
|
||||
Remove-Item -Path $ElysiumPath -Recurse -Force -Exclude $scriptPath
|
||||
Write-Host "Elysium tool and all related files have been removed, excluding this script. This script will be deleted shortly."
|
||||
# PowerShell reads the whole script into memory before execution begins, so it holds no
|
||||
# open file handle on this script - deleting the install directory (including this file)
|
||||
# while still running is safe and needs no deferred external delete process.
|
||||
Remove-Item -Path $ElysiumPath -Recurse -Force
|
||||
Write-Host "Elysium tool and all related files have been removed."
|
||||
} else {
|
||||
Write-Host "Elysium directory not found. It might have been removed already, or the path is incorrect."
|
||||
}
|
||||
|
||||
# Additional cleanup actions can be added here if needed
|
||||
}
|
||||
|
||||
Start-UninstallTranscript
|
||||
|
||||
+18
-3
@@ -7,7 +7,7 @@
|
||||
##################################################
|
||||
## Project: Elysium ##
|
||||
## File: Update-KHDB.ps1 ##
|
||||
## Version: 2.4.4 ##
|
||||
## Version: 2.4.6 ##
|
||||
## Support: support@cqre.net ##
|
||||
##################################################
|
||||
|
||||
@@ -76,6 +76,7 @@ function Invoke-DownloadWithRetry {
|
||||
$retries = 5
|
||||
$delay = 2
|
||||
for ($attempt = 0; $attempt -lt $retries; $attempt++) {
|
||||
$response = $null
|
||||
try {
|
||||
$response = $Client.GetAsync($Uri, [System.Net.Http.HttpCompletionOption]::ResponseHeadersRead).Result
|
||||
if (-not $response.IsSuccessStatusCode) {
|
||||
@@ -115,6 +116,8 @@ function Invoke-DownloadWithRetry {
|
||||
} else {
|
||||
throw
|
||||
}
|
||||
} finally {
|
||||
if ($response) { $response.Dispose() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -264,8 +267,11 @@ function Validate-Manifest {
|
||||
if (-not $seen.Add($name)) { throw "Manifest contains duplicate shard name '$name'." }
|
||||
}
|
||||
|
||||
if ($Manifest.shardSize -and [int]$Manifest.shardSize -ne 2) {
|
||||
throw "Manifest shardSize $($Manifest.shardSize) is not supported. Expected shardSize 2."
|
||||
# Merge-ShardsToFile reads shard files purely by name/content and doesn't use shardSize for
|
||||
# anything structural, so any prefix length Prepare-KHDBStorage.ps1 can produce (1-8, see its
|
||||
# -ShardSize ValidateRange) is fine here - only reject something outside that supported range.
|
||||
if ($Manifest.shardSize -and ([int]$Manifest.shardSize -lt 1 -or [int]$Manifest.shardSize -gt 8)) {
|
||||
throw "Manifest shardSize $($Manifest.shardSize) is out of the supported range (1-8)."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -770,6 +776,15 @@ function Update-KHDB {
|
||||
$backupPath = Join-Path -Path $installPath -ChildPath ("$khdbName.bak-$ts")
|
||||
Copy-Item -LiteralPath $combinedTarget -Destination $backupPath -Force
|
||||
Write-Host ("Existing KHDB backed up to {0}" -f $backupPath)
|
||||
|
||||
# Each run adds one full-size backup; keep only the most recent few so a recurring
|
||||
# scheduled job doesn't silently accumulate backups until the disk fills up.
|
||||
$backupRetentionCount = 5
|
||||
$staleBackups = Get-ChildItem -LiteralPath $installPath -Filter "$khdbName.bak-*" -File -ErrorAction SilentlyContinue |
|
||||
Sort-Object Name -Descending | Select-Object -Skip $backupRetentionCount
|
||||
foreach ($stale in $staleBackups) {
|
||||
try { Remove-Item -LiteralPath $stale.FullName -Force } catch { Write-Warning "Could not remove old backup '$($stale.FullName)': $($_.Exception.Message)" }
|
||||
}
|
||||
}
|
||||
|
||||
Move-Item -LiteralPath $combinedTemp -Destination $combinedTarget -Force
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
##################################################
|
||||
## Project: Elysium ##
|
||||
## File: Update-LithnetStore.ps1 ##
|
||||
## Version: 2.4.4 ##
|
||||
## Version: 2.4.6 ##
|
||||
## Support: support@cqre.net ##
|
||||
##################################################
|
||||
|
||||
|
||||
Reference in New Issue
Block a user