22 Commits

Author SHA1 Message Date
tomas.kracmar 2d90656e5c Release v2.4.6: security/correctness fixes from full codebase review
Consolidates this session's review findings, each already fixed and
committed individually: Uninstall.ps1 deleting the wrong directory,
plaintext passphrase handling, a DCSync ACL pre-check bypass via UPN
credentials, an orchestrator that crashed instead of returning to its
menu, plaintext-hash exposure window and unauthenticated AES export
in Extract-NTHashes.ps1, KHDB shard-size/backup/dedup issues in
Prepare-KHDBStorage.ps1 and Update-KHDB.ps1, and assorted resource
leaks. See CHANGELOG.md for the full list.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 12:10:26 +02:00
tomas.kracmar 5b761d8d56 fix(Extract-NTHashes): add HMAC-SHA256 to the AES export (breaking format bump to ELY2)
Protect-FileWithAES used AES-256-CBC with no integrity check. A wrong
passphrase or corrupted/tampered ciphertext just decrypts to garbage
(or an unhelpful padding exception) instead of being detected.

Derives a second 32-byte key from the same PBKDF2 stream (the AES key
and HMAC key are sequential, non-overlapping ranges of one
Rfc2898DeriveBytes instance) and computes HMAC-SHA256 over
magic+salt+iv+ciphertext (encrypt-then-MAC), appended as a trailer.
Bumped the format magic from 'ELY1' to 'ELY2' so old and new files
are distinguishable.

This is a breaking change for whatever external tooling decrypts
these exports (this repo only ever encrypts - decryption happens on
a separate air-gapped machine per the README's FAQ) - documented the
new layout and the break in the README.

Verified with an isolated round-trip test (function extracted,
dot-sourced, paired with a hand-written decrypt+HMAC-verify): correct
passphrase round-trips cleanly, a wrong passphrase is rejected via
HMAC mismatch, and a single flipped ciphertext byte is also rejected
via HMAC mismatch, in all cases before any AES decryption is
attempted.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 12:10:04 +02:00
tomas.kracmar 80199ad7d6 fix(Extract-NTHashes): pin plaintext hash export to UTF-8
Out-File defaults to UTF-16LE on Windows PowerShell 5.1 (Desktop) and
UTF-8 on PowerShell 7 (Core), so the encoding of the transient
plaintext hash export depended purely on which host happened to run
the script - inconsistent with Test-WeakADPasswords.ps1/
Update-KHDB.ps1, which already pin this. Applied the same
$PSDefaultParameterValues['Out-File:Encoding'] = 'utf8' pattern.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 12:06:12 +02:00
tomas.kracmar eea0ddf932 fix(Update-KHDB): dispose HttpResponseMessage on download
Invoke-DownloadWithRetry disposed the response stream and file handle
but never the HttpResponseMessage itself returned by GetAsync,
leaking one per shard download (the S3 code path already disposes
its response object). Added a finally block per attempt to dispose
it on both the success and retry/failure paths.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 12:05:30 +02:00
tomas.kracmar 2f0ff9085a fix(Prepare-KHDBStorage): reject manifest shard names outside shard root
In -UploadOnly mode, entry.name from the parsed manifest.json was
used directly in Join-Path (local read) and as the remote object key
with no path-traversal check. A tampered local manifest.json
(requires prior local write access to the shard directory) with a
name like '..\..\secrets.txt' could make the upload step read and
upload an arbitrary local file under an attacker-chosen remote key.
Now resolves each shard's full path via GetFullPath and rejects any
entry that resolves outside the shard root, before it's added to the
list actually used for reads/uploads. Verified the resolved-path
containment check against normal relative names (allowed) and '..'
traversal attempts (blocked).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 12:04:43 +02:00
tomas.kracmar 03ceec9d5e fix(Elysium.Common): dispose DirectoryEntry/ADSI objects
Test-ReplicationPermissions and Test-DCClockSkew each build a
DirectoryEntry (holding a live ADSI/COM binding, with a plaintext
credential) and never call .Dispose(), leaking the underlying COM
resource on every invocation - noticeable across a run that checks
multiple domains/DCs in one session. Added finally blocks to dispose
both.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 12:03:31 +02:00
tomas.kracmar 06e7607eff fix(Prepare-KHDBStorage): fast-path dedup, and a scope bug hiding it
Two related bugs in Split-KhdbIntoShards:

1. The fast path for plain 32-hex-char lines wrote every line
   straight to its shard with no deduplication, unlike the slow path
   which merges adjacent duplicate hashes via a per-shard pending-line
   buffer. Routed the fast path through the same pending-line buffer
   so adjacent duplicates are dropped there too.

2. While fixing that, found $total (the valid-entry counter) was a
   plain scalar incremented inside $processHashLine, which is invoked
   via the call operator (&). Scriptblocks invoked with & run in
   their own child scope, so '$total++' inside them silently
   incremented a local shadow copy and never updated the real
   counter in the enclosing function - meaning TotalEntries/
   validEntries (used in the returned result, the checkpoint state,
   and the live progress status) were wrong on every run, previously
   masked because actual shard file writes go through $state.Writer
   objects (reference types, unaffected by the scoping issue).
   Moved the counter onto $meta (already a hashtable used the same
   way for TotalLines/InvalidLines/etc.) so it mutates by reference
   across the scope boundary.

Verified both with an isolated test harness (function extracted and
dot-sourced, no AD/Windows dependency): adjacent AAAA/CCCC duplicates
in a 6-line sample now collapse to 3 shard entries, and TotalEntries
correctly reports 3 instead of the previous 0/wrong value.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 12:01:28 +02:00
tomas.kracmar 9bef6d50f5 fix(Test-WeakADPasswords): prevent report column truncation
$testResults | Out-String used the default formatter width, which
truncates long columns at the console/default width. The report text
is re-parsed line-by-line to attach "UPN:" annotations, taking the
first whitespace token as SamAccountName - a truncated account name
would silently fail to match $dictionarySamToUpn and drop the
annotation with no error. Pass -Width 4096 to avoid truncation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 11:54:47 +02:00
tomas.kracmar d155276366 fix(Extract-NTHashes): remove mismatched blob after checksum failure
On upload checksum mismatch, the script warned and preserved the
local encrypted file but left the already-uploaded, corrupt blob live
in remote storage under its normal name - discoverable only via an
easy-to-miss console warning, and fetchable as if it were good data
by any downstream consumer. Added Invoke-S3DeleteFile (SigV4-signed,
mirrors the existing Put/Get helpers) and now delete the mismatched
blob from S3 (AWS Tools or native HTTP path) or Azure Blob Storage
right after detecting the mismatch.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 11:54:21 +02:00
tomas.kracmar 867fb6427d fix(Update-KHDB): cap KHDB backup retention
Every successful update created a new khdb.txt.bak-<timestamp> with
no cleanup anywhere, so a recurring scheduled job accumulated one
full-size backup per run indefinitely. Now keeps only the 5 most
recent backups, pruning older ones after each new backup is created.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 11:53:26 +02:00
tomas.kracmar 1440b65b9a fix(Update-KHDB): accept manifest's full supported shardSize range
Validate-Manifest hardcoded shardSize -ne 2 as an error, but
Prepare-KHDBStorage.ps1's -ShardSize parameter validly accepts 1-8.
Merge-ShardsToFile doesn't use shardSize for anything structural - it
reads shard files purely by name/content - so the check added no
safety, only rejected manifests produced with any supported shard
size other than 2. Widened the check to the same 1-8 range instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 11:52:58 +02:00
tomas.kracmar 5691463bd3 fix(Elysium): catch sub-script errors instead of crashing the menu
The menu loop had try/finally with no catch. Update-KHDB.ps1,
Test-WeakADPasswords.ps1, and Extract-NTHashes.ps1 all run under
$ErrorActionPreference = 'Stop' and re-throw on failure, so any
runtime error (bad credentials, unreachable DC, network failure)
propagated uncaught through the parent and killed the whole
orchestrator instead of returning to the menu. Wrapped the switch in
try/catch so a failed option reports the error and redisplays the
menu.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 11:52:13 +02:00
tomas.kracmar 91d6bcb216 fix(Test-ReplicationPermissions): resolve UPN-formatted credentials
Get-ADUser -Identity accepts a DN, GUID, SID, or sAMAccountName - not
a UPN. The SID/tokenGroups resolution stripped only a 'DOMAIN\'
prefix, so a UPN-formatted credential (user@domain.tld) had no
backslash to strip, -Identity threw on the full UPN, and the
exception was swallowed by the surrounding catch (Write-Warning +
return), silently skipping the entire DCSync ACL pre-check.

Now branches on the credential format: DOMAIN\user strips the prefix
as before, user@domain.tld resolves via
-Filter "UserPrincipalName -eq '...'", and a bare sAMAccountName is
used as-is. The follow-up tokenGroups lookup now uses the resolved
DistinguishedName instead of re-deriving the username format.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 11:51:41 +02:00
tomas.kracmar ec00518952 fix(passphrase): store DPAPI-protected, never plaintext or echoed
Elysium.ps1 used Read-Host without -AsSecureString (echoed the AES
passphrase to the console) and persisted it as plaintext in
HKCU\Environment via SetEnvironmentVariable, readable by anyone with
console visibility or local registry access. Switched to
Read-Host -AsSecureString and store ConvertFrom-SecureString's DPAPI
output (decryptable only by the same user on the same machine)
instead of the raw value.

Extract-NTHashes.ps1, the only other consumer, now decrypts that
DPAPI-protected string back to a plain string via
ConvertTo-SecureString + NetworkCredential right before handing it to
Protect-FileWithAES. A stale plaintext value from a prior version is
detected and the operator is prompted to re-enter it.

DPAPI protection is Windows-only, consistent with the rest of this
Windows/AD-only tool; not runnable end-to-end on this (non-Windows)
dev machine, verified by AST parse only.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 11:50:59 +02:00
tomas.kracmar 4740cd3e97 fix(Extract-NTHashes): restrict ACL on temp dir holding plaintext hashes
Live NTLM hashes are written unencrypted to a temp file before AES
protection is applied. Cleanup only runs in a finally block, so a
hard kill/crash between write and cleanup could leave plaintext
hashes on disk under a directory that inherits whatever broad ACL its
parent Temp folder has (worst case: C:\Windows\Temp when run as
SYSTEM). Strip inherited ACEs and grant only the current user on the
temp directory right after creating it, narrowing exposure for that
window.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 11:50:16 +02:00
tomas.kracmar 855be8de9c fix(Uninstall): use script location, fix broken self-delete
Two bugs in Uninstall-Elysium:
- $ElysiumPath came from Get-Location (the caller's current working
  directory) instead of $PSScriptRoot. Running the script from any
  CWD other than the install folder recursively force-deleted the
  wrong directory.
- The self-delete workaround was also broken: Remove-Item -Exclude
  matches leaf names via wildcard, not the full path it was given, so
  the exclusion never matched; and the deferred
  "powershell.exe -Command <scriptblock> -ArgumentList <path>" command
  line is not valid syntax for binding $path in the child process.

PowerShell reads a script fully into memory before running it, so it
holds no open handle on the file - deleting the install directory
(including this script) while it's still executing is safe. Dropped
the exclude/deferred-process workaround entirely.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 11:49:47 +02:00
tomas.kracmar ac3f30db1e fix(Bump-Version): param() must be script's first statement
$ErrorActionPreference and Set-StrictMode sat before param(), so
PowerShell parsed 'param(...)' as a call to a command named param
instead of the script's parameter block ("The function or command
was called as if it were a method"). The script never ran. Moved
both statements after the param block; only #Requires and the
comment-based help may precede param().

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 11:49:21 +02:00
tomas.kracmar 65e451413e Release v2.4.5: detect explicit Deny ACEs in replication permission check
Test-ReplicationPermissions previously only scanned Allow ACEs, so an
explicit Deny on the DCSync extended rights (common in hardening
baselines that Deny a broad group and Allow only named service
accounts) was invisible to the pre-flight 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.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 11:49:14 +02:00
tomas.kracmar 1d98b908c6 Release v2.4.4: check schema NC replication rights for DSInternals 7.0
DSInternals 7.0 fetches the AD schema via DRS (GetNCChanges) before
replicating accounts, so the schema NC has its own ACL requirement.

- Test-ReplicationPermissions now validates rights on both the
  domain NC and the configuration NC (schema NC inherits from it).
- Updated README with dsacls delegation examples and dual-NC
  least-privilege requirements.
- Improved 'Replication access was denied' error message to name
  both NCs and explain the DSInternals 7.0 change.
- Diagnostic dump now includes SchemaDN.

All versions bumped to unified v2.4.4.
2026-06-15 08:38:04 +02:00
tomas.kracmar 906bb52638 fix(Test-WeakADPasswords): add comprehensive DCSync diagnostic dump
When Get-ADReplAccount or Test-PasswordQuality throws, the catch
block now dumps the full exception chain (type, message, HResult,
source, target site, stack trace, inner exceptions) along with
runtime context (Elysium version, PS version, DSInternals version,
DC, domain, account). Output goes to console and a timestamped
 diagnostic file under Reports/ for offline analysis.
2026-06-09 16:23:38 +02:00
tomas.kracmar af945f529e Release v2.4.3: fix tokenGroups retrieval and DirectoryEntry LDAP paths
Test-ReplicationPermissions:
- Replaced DirectoryEntry.RefreshCache tokenGroups retrieval with
  Get-ADUser -Properties tokenGroups. DirectoryEntry does not
  understand URI percent-encoding, so the v2.4.1 EscapeDataString
  fix caused 'invalid dn syntax' errors.
- Removed EscapeDataString from the ACL DirectoryEntry path as
  well; DirectoryEntry expects raw LDAP ADSI path syntax.

All versions bumped to unified v2.4.3.
2026-06-09 14:14:45 +02:00
tomas.kracmar 03aa72f999 Release v2.4.2: replace em-dashes with ASCII hyphens to fix encoding parse errors
UTF-8 em-dashes (U+2014) in Elysium.Common.ps1 string literals were
being misinterpreted by Windows PowerShell as containing quote
characters when the file was read without a UTF-8 BOM. This caused
cascading parse errors: unexpected tokens, missing closing braces,
and missing catch blocks.

All em-dashes in .ps1 files have been replaced with ASCII hyphens.
All versions bumped to unified v2.4.2.
2026-06-09 13:51:13 +02:00
12 changed files with 446 additions and 146 deletions
+5 -4
View File
@@ -8,13 +8,11 @@
##################################################
## Project: Elysium ##
## File: Bump-Version.ps1 ##
## Version: 2.4.1 ##
## 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 }
@@ -149,7 +150,7 @@ if (-not $SkipChangelog) {
---
## [$NewVersion] $today
## [$NewVersion] - $today
### Changed
- (describe your change here)
+49
View File
@@ -6,6 +6,55 @@ 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
- `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
+118 -59
View File
@@ -1,4 +1,4 @@
$script:ElysiumVersion = '2.4.1'
$script:ElysiumVersion = '2.4.6'
function Invoke-RestartWithExecutable {
param(
@@ -328,29 +328,51 @@ function Test-ReplicationPermissions {
[Parameter(Mandatory)][System.Management.Automation.PSCredential]$Credential
)
$requiredRights = [ordered]@{
$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
# 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
$userDe = New-Object System.DirectoryServices.DirectoryEntry(
"LDAP://$Server/$([System.Uri]::EscapeDataString($adUser.DistinguishedName))",
$Credential.UserName,
$Credential.GetNetworkCredential().Password
)
$userDe.RefreshCache(@('tokenGroups'))
foreach ($sidBytes in $userDe.Properties['tokenGroups']) {
$sid = New-Object System.Security.Principal.SecurityIdentifier($sidBytes, 0)
# 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)
[void]$callerSids.Add($sid.Value)
}
@@ -364,63 +386,97 @@ function Test-ReplicationPermissions {
$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)
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
}
$acl = $null
try {
$de = New-Object System.DirectoryServices.DirectoryEntry(
"LDAP://$Server/$([System.Uri]::EscapeDataString($DomainDN))",
$Credential.UserName,
$Credential.GetNetworkCredential().Password
)
$acl = $de.ObjectSecurity.GetAccessRules(
$true, $true, [System.Security.Principal.SecurityIdentifier])
} catch {
Write-Warning ("Could not read domain object ACL for replication permission pre-check: {0}. Skipping." -f $_.Exception.Message)
return
}
$allMissingLines = @()
$missing = @()
foreach ($rightName in $requiredRights.Keys) {
$guid = $requiredRights[$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 domain 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 }
foreach ($ncEntry in $ncsToCheck.GetEnumerator()) {
$ncDN = $ncEntry.Key
$rightsToCheck = $ncEntry.Value
$acl = $null
$de = $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
} finally {
if ($de) { $de.Dispose() }
}
if (-not $granted) {
$hint = if ($aceExistsForGuid) {
' (ACE exists on the domain object but is not assigned to this account or any of its groups)'
} else {
' (no ACE found for this right on the domain object at all)'
foreach ($rightName in $rightsToCheck.Keys) {
$guid = $rightsToCheck[$rightName]
$granted = $false
$aceExistsForGuid = $false
$denyIdentity = $null
foreach ($ace in $acl) {
# 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 (-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 }
$granted = $true
}
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 {
' (no ACE found for this right on this object)'
}
$allMissingLines += "[on $ncDN] $rightName$hint"
}
$missing += $rightName + $hint
}
}
if ($missing.Count -gt 0) {
throw ("Account '{0}' failed replication permission check 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 - "))
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.")
}
$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}'." -f $Credential.UserName)
Write-Host ("[+] Replication permissions verified for '{0}' on domain NC and schema NC." -f $Credential.UserName)
}
function Test-DCClockSkew {
@@ -428,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",
@@ -441,13 +498,15 @@ function Test-DCClockSkew {
[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)
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)
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)
} finally {
if ($rootDse) { $rootDse.Dispose() }
}
}
+23 -10
View File
@@ -7,7 +7,7 @@
##################################################
## Project: Elysium ##
## File: Elysium.ps1 ##
## Version: 2.4.1 ##
## 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 {
+1 -1
View File
@@ -8,7 +8,7 @@
##################################################
## Project: Elysium ##
## File: ElysiumSettings.txt ##
## Version: 2.4.1 ##
## Version: 2.4.4 ##
## Support: support@cqre.net ##
##################################################
+88 -26
View File
@@ -7,7 +7,7 @@
##################################################
## Project: Elysium ##
## File: Extract-NTHashes.ps1 ##
## Version: 2.4.1 ##
## 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
View File
@@ -7,7 +7,7 @@
##################################################
## Project: Elysium ##
## File: Prepare-KHDBStorage.ps1 ##
## Version: 2.4.1 ##
## 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'."
}
+34 -6
View File
@@ -12,7 +12,7 @@ Sensitive operations are confined only to the dedicated host. In the third step,
## Prerequisities
* **Windows Host:** A Windows machine with PowerShell and DSInternals suite installed.
* **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).
## Versioning and Releases
@@ -58,20 +58,46 @@ 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.
#### 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 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
- `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.
- `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.
- `Get-ADReplAccount: Access is denied`:
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.
- `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.
@@ -82,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.
+60 -9
View File
@@ -8,7 +8,7 @@
##################################################
## Project: Elysium ##
## File: Test-WeakADPasswords.ps1 ##
## Version: 2.4.1 ##
## Version: 2.4.6 ##
## Support: support@cqre.net ##
##################################################
@@ -631,16 +631,64 @@ function Test-WeakADPasswords {
$testResults = $accounts | Test-PasswordQuality -WeakPasswordHashesSortedFile $resolvedHashFile.Path
Write-Verbose "Password quality test completed."
} catch {
$message = $_.Exception.Message
if ($message -match 'Access is denied') {
Write-Error ("Access denied while reading replication data from '{0}' using '{1}'. Ensure this account has Replicating Directory Changes, Replicating Directory Changes All, and Replicating Directory Changes In Filtered Set on the domain." -f $selectedDomain["DC"], $credential.UserName)
return
$ex = $_.Exception
$diagLines = [System.Collections.Generic.List[string]]::new()
$diagLines.Add('========================================')
$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 ($currentEx.StackTrace) {
$diagLines.Add(" StackTrace :`n$($currentEx.StackTrace -replace '^', ' ')")
}
$diagLines.Add('')
$currentEx = $currentEx.InnerException
$depth++
}
if ($message -match 'rejected the client credentials|unknown user name|bad password|logon failure') {
$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"])
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
} finally {
if ($resolvedHashFile -and $resolvedHashFile.IsTemporary -and (Test-Path -LiteralPath $resolvedHashFile.Path)) {
@@ -677,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
View File
@@ -7,7 +7,7 @@
##################################################
## Project: Elysium ##
## File: Uninstall.ps1 ##
## Version: 2.4.1 ##
## 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
View File
@@ -7,7 +7,7 @@
##################################################
## Project: Elysium ##
## File: Update-KHDB.ps1 ##
## Version: 2.4.1 ##
## 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
+1 -1
View File
@@ -7,7 +7,7 @@
##################################################
## Project: Elysium ##
## File: Update-LithnetStore.ps1 ##
## Version: 2.4.1 ##
## Version: 2.4.6 ##
## Support: support@cqre.net ##
##################################################