Compare commits
22 Commits
v2.4.1
...
2d90656e5c
| Author | SHA1 | Date | |
|---|---|---|---|
| 2d90656e5c | |||
| 5b761d8d56 | |||
| 80199ad7d6 | |||
| eea0ddf932 | |||
| 2f0ff9085a | |||
| 03ceec9d5e | |||
| 06e7607eff | |||
| 9bef6d50f5 | |||
| d155276366 | |||
| 867fb6427d | |||
| 1440b65b9a | |||
| 5691463bd3 | |||
| 91d6bcb216 | |||
| ec00518952 | |||
| 4740cd3e97 | |||
| 855be8de9c | |||
| ac3f30db1e | |||
| 65e451413e | |||
| 1d98b908c6 | |||
| 906bb52638 | |||
| af945f529e | |||
| 03aa72f999 |
+5
-4
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
##################################################
|
||||
## Project: Elysium ##
|
||||
## File: ElysiumSettings.txt ##
|
||||
## Version: 2.4.1 ##
|
||||
## Version: 2.4.4 ##
|
||||
## Support: support@cqre.net ##
|
||||
##################################################
|
||||
|
||||
|
||||
+88
-26
@@ -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
@@ -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'."
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
##################################################
|
||||
## Project: Elysium ##
|
||||
## File: Update-LithnetStore.ps1 ##
|
||||
## Version: 2.4.1 ##
|
||||
## Version: 2.4.6 ##
|
||||
## Support: support@cqre.net ##
|
||||
##################################################
|
||||
|
||||
|
||||
Reference in New Issue
Block a user