Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 03aa72f999 | |||
| 10cbf0285d | |||
| fc91f0d6b0 | |||
| 6b2ae6c8b5 | |||
| 37d1a8d971 | |||
| 0175864e72 | |||
| 9496063b97 | |||
| 27a682a968 | |||
| 255cfe0a17 | |||
| 09c30f97e9 | |||
| 5127c2d096 |
@@ -0,0 +1,161 @@
|
|||||||
|
##################################################
|
||||||
|
## ____ ___ ____ _____ _ _ _____ _____ ##
|
||||||
|
## / ___/ _ \| _ \| ____| | \ | | ____|_ _| ##
|
||||||
|
## | | | | | | |_) | _| | \| | _| | | ##
|
||||||
|
## | |__| |_| | _ <| |___ _| |\ | |___ | | ##
|
||||||
|
## \____\__\_\_| \_\_____(_)_| \_|_____| |_| ##
|
||||||
|
## Move fast and fix things. ##
|
||||||
|
##################################################
|
||||||
|
## Project: Elysium ##
|
||||||
|
## File: Bump-Version.ps1 ##
|
||||||
|
## Version: 2.4.2 ##
|
||||||
|
## Support: support@cqre.net ##
|
||||||
|
##################################################
|
||||||
|
|
||||||
|
#Requires -Version 5.1
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
Set-StrictMode -Version Latest
|
||||||
|
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Bumps the unified Elysium version across all project files.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Updates the centralized $ElysiumVersion variable, ASCII headers in all
|
||||||
|
operational scripts, the settings template, and runtime references
|
||||||
|
(User-Agent, usage beacon, etc.). Optionally stubs a new CHANGELOG entry.
|
||||||
|
|
||||||
|
.PARAMETER NewVersion
|
||||||
|
The new version string to apply (e.g. 2.2.2).
|
||||||
|
|
||||||
|
.PARAMETER SkipChangelog
|
||||||
|
Do not print a CHANGELOG entry stub.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\Bump-Version.ps1 -NewVersion 2.2.2
|
||||||
|
#>
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$NewVersion,
|
||||||
|
|
||||||
|
[switch]$SkipChangelog
|
||||||
|
)
|
||||||
|
|
||||||
|
$scriptRoot = $PSScriptRoot
|
||||||
|
if (-not $scriptRoot) { $scriptRoot = (Get-Location).Path }
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Validate input
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
if ($NewVersion -notmatch '^\d+\.\d+\.\d+$') {
|
||||||
|
throw "Version must be in semantic format X.Y.Z (e.g. 2.2.2). Got: '$NewVersion'"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Determine current version from Elysium.Common.ps1
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
$commonPath = Join-Path -Path $scriptRoot -ChildPath 'Elysium.Common.ps1'
|
||||||
|
if (-not (Test-Path -LiteralPath $commonPath)) {
|
||||||
|
throw "Elysium.Common.ps1 not found at $commonPath"
|
||||||
|
}
|
||||||
|
|
||||||
|
$commonContent = Get-Content -LiteralPath $commonPath -Raw
|
||||||
|
$currentVersionMatch = [regex]::Match($commonContent, "\`$script:ElysiumVersion\s*=\s*'([^']+)'")
|
||||||
|
if (-not $currentVersionMatch.Success) {
|
||||||
|
throw "Could not determine current version from Elysium.Common.ps1"
|
||||||
|
}
|
||||||
|
$oldVersion = $currentVersionMatch.Groups[1].Value
|
||||||
|
|
||||||
|
if ($oldVersion -eq $NewVersion) {
|
||||||
|
Write-Warning "Current version is already $NewVersion. Nothing to do."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Bumping Elysium from $oldVersion -> $NewVersion"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helper: replace in file
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
function Edit-FileVersion {
|
||||||
|
param(
|
||||||
|
[string]$Path,
|
||||||
|
[string]$Old,
|
||||||
|
[string]$New
|
||||||
|
)
|
||||||
|
$content = Get-Content -LiteralPath $Path -Raw
|
||||||
|
$newContent = $content.Replace($Old, $New)
|
||||||
|
if ($newContent -eq $content) {
|
||||||
|
Write-Verbose " No changes in $(Split-Path -Leaf $Path)"
|
||||||
|
} else {
|
||||||
|
Set-Content -LiteralPath $Path -Value $newContent -NoNewline -Encoding UTF8
|
||||||
|
Write-Host " Updated $(Split-Path -Leaf $Path)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 1. Central version variable
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
Write-Host "`n[1/4] Updating centralized version variable..."
|
||||||
|
Edit-FileVersion -Path $commonPath -Old "`$script:ElysiumVersion = '$oldVersion'" -New "`$script:ElysiumVersion = '$NewVersion'"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 2. ASCII headers in scripts and templates
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
Write-Host "`n[2/4] Updating script headers..."
|
||||||
|
$headerTargets = Get-ChildItem -Path $scriptRoot -File | Where-Object {
|
||||||
|
$_.Extension -in @('.ps1', '.py') -or $_.Name -eq 'ElysiumSettings.txt.sample'
|
||||||
|
}
|
||||||
|
foreach ($file in $headerTargets) {
|
||||||
|
$content = Get-Content -LiteralPath $file.FullName -Raw
|
||||||
|
# The header pattern: ## Version: X.Y.Z ##
|
||||||
|
$pattern = "## Version:\s+$([regex]::Escape($oldVersion))\s+##"
|
||||||
|
$replacement = "## Version: $NewVersion ##"
|
||||||
|
$newContent = [regex]::Replace($content, $pattern, $replacement)
|
||||||
|
if ($newContent -ne $content) {
|
||||||
|
Set-Content -LiteralPath $file.FullName -Value $newContent -NoNewline -Encoding UTF8
|
||||||
|
Write-Host " Updated $($file.Name)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 3. Runtime references (safety net)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
Write-Host "`n[3/4] Updating runtime version references..."
|
||||||
|
$runtimeTargets = Get-ChildItem -Path $scriptRoot -Filter '*.ps1' -File
|
||||||
|
foreach ($file in $runtimeTargets) {
|
||||||
|
$content = Get-Content -LiteralPath $file.FullName -Raw
|
||||||
|
$newContent = $content
|
||||||
|
# User-Agent patterns: 'Elysium/2.2.1 (+...)' or "Elysium/2.2.1 (+...)"
|
||||||
|
$newContent = [regex]::Replace($newContent,
|
||||||
|
"Elysium/$([regex]::Escape($oldVersion))",
|
||||||
|
"Elysium/$NewVersion")
|
||||||
|
# Literal string assignments: version = '2.2.1'
|
||||||
|
$newContent = [regex]::Replace($newContent,
|
||||||
|
"version\s*=\s*'$([regex]::Escape($oldVersion))'",
|
||||||
|
"version = '$NewVersion'")
|
||||||
|
if ($newContent -ne $content) {
|
||||||
|
Set-Content -LiteralPath $file.FullName -Value $newContent -NoNewline -Encoding UTF8
|
||||||
|
Write-Host " Updated runtime refs in $($file.Name)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 4. CHANGELOG stub
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
if (-not $SkipChangelog) {
|
||||||
|
Write-Host "`n[4/4] CHANGELOG entry stub (copy-paste ready):"
|
||||||
|
$today = (Get-Date).ToString('yyyy-MM-dd')
|
||||||
|
$stub = @"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [$NewVersion] - $today
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- (describe your change here)
|
||||||
|
"@
|
||||||
|
Write-Host $stub
|
||||||
|
Write-Host "`nAppend the above to CHANGELOG.md, then commit and tag."
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "`nDone. Review the changes with: git diff --stat"
|
||||||
@@ -6,6 +6,88 @@ Starting with **v2.2.0**, Elysium uses a **unified project version**. All script
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [2.4.2] — 2026-06-09
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Replaced UTF-8 em-dashes (`\u2014`) in `Elysium.Common.ps1` and `Bump-Version.ps1` with ASCII hyphens. On Windows PowerShell without a UTF-8 BOM, the three-byte em-dash sequence was misinterpreted as containing a quote character, causing cascading parse errors (unexpected token, missing closing `)`/`}`/`catch`, etc.).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.4.1] — 2026-06-09
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- `Test-ReplicationPermissions` and `Test-DCClockSkew` now URI-escape Distinguished Names via `[System.Uri]::EscapeDataString` before embedding them in `DirectoryEntry` LDAP URLs. DNs containing `/`, `#`, or other reserved characters previously caused URL mis-parsing and constructor failures.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.4.0] — 2026-06-09
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **DC clock skew pre-flight check** (`Test-DCClockSkew` in `Elysium.Common.ps1`): compares the local machine clock against the target DC's `RootDSE.currentTime` before attempting DCSync. Warns if skew exceeds 300s (Kerberos hard limit) or 60s (approaching limit), and provides the `w32tm /resync /force` remediation command.
|
||||||
|
- **SDProp protection warning** in `Test-ReplicationPermissions`: detects `adminCount=1` on the service account and warns that SDProp runs every 60 minutes and may silently revert replication rights or group memberships.
|
||||||
|
- **Protected Users group warning** in `Test-ReplicationPermissions`: detects membership in the Protected Users group (RID 525) and warns that it restricts Kerberos delegation and RC4 authentication required by DSInternals for DRS replication.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- DSInternals auto-update flow now uses `Install-Module -Force -AllowClobber` instead of `Update-Module` to avoid a PowerShellGet bug where null `PublishedDate` metadata causes "cannot convert null to type system.datetime".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.3.0] — 2026-06-09
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- `Test-WeakADPasswords.ps1` now checks the installed DSInternals version at startup:
|
||||||
|
- **v6.2** (unsigned) is flagged with a warning explaining that unsigned native DLLs are blocked and replication will fail. Remediation: `Update-Module DSInternals`.
|
||||||
|
- **Below v7.0** triggers an interactive prompt offering to run `Update-Module DSInternals -Force` automatically. If accepted, the script updates the module and exits cleanly so the operator can re-run with the new version loaded.
|
||||||
|
- v7.0+ is required because it fixes intermittent CRC errors mid-replication and `Test-PasswordQuality` result truncation bugs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.2.5] — 2026-06-09
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- The DSInternals `Zone.Identifier` block error message (added in v2.2.4) now dynamically resolves the actual DSInternals module path via `Get-Module` instead of hardcoding `$env:ProgramFiles\WindowsPowerShell\DSInternals`. The `Unblock-File` command in the error now points to the correct installation directory.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.2.4] — 2026-06-09
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- `Test-ReplicationPermissions` (in `Elysium.Common.ps1`) now skips `InheritOnly` ACEs when evaluating replication rights. An ACE marked `InheritOnly` applies only to child objects, not the domain root itself, so it does not grant the required extended rights for DCSync on the domain object.
|
||||||
|
- `Import-CompatModule` (in `Test-WeakADPasswords.ps1`) now detects DSInternals being blocked by Windows `Zone.Identifier` (alternate data stream from internet download) and throws a clear, actionable error with the exact `Unblock-File` command to run. Previously this surfaced as an opaque non-FIPS warning.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.2.3] — 2026-06-09
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- `Test-ReplicationPermissions` (in `Elysium.Common.ps1`) now correctly recognizes `GenericAll` and blanket `ExtendedRight` (empty ObjectType) ACEs as satisfying replication permission requirements. Previously, only exact GUID-matched ExtendedRight ACEs were detected, causing false negatives when rights were granted via broader permissions.
|
||||||
|
- Improved error diagnostics: the missing-rights message now indicates whether an ACE for the specific right exists on the domain object but is not assigned to the caller, versus no ACE existing at all.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.2.2] — 2026-06-09
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- `Test-ReplicationPermissions` (in `Elysium.Common.ps1`) now resolves the caller's **effective token SIDs** via the `tokenGroups` constructed attribute instead of walking `MemberOf` directly. This correctly accounts for nested group memberships and avoids false-positive "missing permissions" errors when the account is entitled through nested groups.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.2.1] — 2026-06-09
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **DRY refactoring — shared helpers consolidated into `Elysium.Common.ps1`:**
|
||||||
|
- Moved `Read-KeyValueSettingsFile`, `Read-ElysiumSettings`, and `Get-SettingsValue` from `Prepare-KHDBStorage.ps1` and `Update-KHDB.ps1` into the common helper.
|
||||||
|
- Moved `Build-BlobUri` and Azure URI helpers from `Update-KHDB.ps1` into the common helper.
|
||||||
|
- Moved `Get-FunctionDefinitionText` from all scripts that duplicated it into the common helper.
|
||||||
|
- Moved `Get-ValidatedADCredential` and `Test-ReplicationPermissions` from `Test-WeakADPasswords.ps1` into the common helper.
|
||||||
|
- Moved all native S3 SigV4 helpers (`Ensure-AWSS3Module`, `New-S3Client`, `HmacSha256`, `GetSignatureKey`, `BuildAuthHeaders`, `BuildS3Uri`, etc.) from `Extract-NTHashes.ps1` into the common helper.
|
||||||
|
- `Test-WeakADPasswords.ps1` and `Extract-NTHashes.ps1` now import `Elysium.Common.ps1` (they previously did not), reducing duplication and ensuring consistent behavior.
|
||||||
|
- `Update-KHDB.ps1` and `Prepare-KHDBStorage.ps1` removed their local copies of helpers already available in the common module.
|
||||||
|
- Removed legacy `Settings.ps1` (superseded by `ElysiumSettings.txt`).
|
||||||
|
- Minor cleanup: removed stray placeholder comment in `Elysium.ps1`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [2.2.0] — 2026-06-09
|
## [2.2.0] — 2026-06-09
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
$script:ElysiumVersion = '2.4.2'
|
||||||
|
|
||||||
function Invoke-RestartWithExecutable {
|
function Invoke-RestartWithExecutable {
|
||||||
param(
|
param(
|
||||||
[string]$ExecutablePath,
|
[string]$ExecutablePath,
|
||||||
@@ -68,3 +70,384 @@ function Restart-WithWindowsPowerShellIfAvailable {
|
|||||||
Write-Host ("Windows PowerShell detected at '{0}'; relaunching script under powershell.exe..." -f $powershellPath)
|
Write-Host ("Windows PowerShell detected at '{0}'; relaunching script under powershell.exe..." -f $powershellPath)
|
||||||
Invoke-RestartWithExecutable -ExecutablePath $powershellPath -BoundParameters $BoundParameters -UnboundArguments $UnboundArguments
|
Invoke-RestartWithExecutable -ExecutablePath $powershellPath -BoundParameters $BoundParameters -UnboundArguments $UnboundArguments
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Settings loading
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function Read-KeyValueSettingsFile {
|
||||||
|
param([Parameter(Mandatory)][string]$Path)
|
||||||
|
$result = @{}
|
||||||
|
if (-not (Test-Path -LiteralPath $Path)) { return $result }
|
||||||
|
foreach ($line in (Get-Content -LiteralPath $Path)) {
|
||||||
|
if ($null -eq $line) { continue }
|
||||||
|
$trimmed = $line.Trim()
|
||||||
|
if (-not $trimmed) { continue }
|
||||||
|
if ($trimmed.StartsWith('#')) { continue }
|
||||||
|
$kv = $line -split '=', 2
|
||||||
|
if ($kv.Count -ne 2) { continue }
|
||||||
|
$key = $kv[0].Trim()
|
||||||
|
$value = $kv[1].Trim()
|
||||||
|
if (-not $key) { continue }
|
||||||
|
if ($value.StartsWith("'") -and $value.EndsWith("'") -and $value.Length -ge 2) {
|
||||||
|
$value = $value.Substring(1, $value.Length - 2)
|
||||||
|
}
|
||||||
|
$result[$key] = $value
|
||||||
|
}
|
||||||
|
return $result
|
||||||
|
}
|
||||||
|
|
||||||
|
function Read-ElysiumSettings {
|
||||||
|
param([Parameter(Mandatory)][string]$ScriptRoot)
|
||||||
|
$settingsPath = Join-Path -Path $ScriptRoot -ChildPath 'ElysiumSettings.txt'
|
||||||
|
if (-not (Test-Path -LiteralPath $settingsPath)) {
|
||||||
|
throw "Settings file not found at $settingsPath"
|
||||||
|
}
|
||||||
|
return Read-KeyValueSettingsFile -Path $settingsPath
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-SettingsValue {
|
||||||
|
param(
|
||||||
|
[hashtable]$Settings,
|
||||||
|
[string]$Key
|
||||||
|
)
|
||||||
|
if (-not $Settings) { return $null }
|
||||||
|
if ($Settings.ContainsKey($Key)) { return $Settings[$Key] }
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Parallel execution helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function Get-FunctionDefinitionText {
|
||||||
|
param([Parameter(Mandatory)][string]$Name)
|
||||||
|
$cmd = Get-Command -Name $Name -CommandType Function -ErrorAction Stop
|
||||||
|
return $cmd.ScriptBlock.Ast.Extent.Text
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Azure Blob Storage helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function Build-BlobUri {
|
||||||
|
param(
|
||||||
|
[string]$Account,
|
||||||
|
[string]$Container,
|
||||||
|
[string]$Sas,
|
||||||
|
[string]$BlobName
|
||||||
|
)
|
||||||
|
|
||||||
|
if ([string]::IsNullOrWhiteSpace($Account)) { throw 'storageAccountName is missing or empty.' }
|
||||||
|
if ([string]::IsNullOrWhiteSpace($Container)) { throw 'containerName is missing or empty.' }
|
||||||
|
if ([string]::IsNullOrWhiteSpace($Sas)) { throw 'sasToken is missing or empty.' }
|
||||||
|
if ([string]::IsNullOrWhiteSpace($BlobName)) { throw 'BlobName cannot be empty.' }
|
||||||
|
|
||||||
|
$sas = $Sas.Trim()
|
||||||
|
if (-not $sas.StartsWith('?')) { $sas = '?' + $sas }
|
||||||
|
$normalizedBlob = $BlobName.Replace('\', '/').TrimStart('/')
|
||||||
|
$builder = [System.UriBuilder]::new("https://$Account.blob.core.windows.net/$Container/$normalizedBlob")
|
||||||
|
$builder.Query = $sas.TrimStart('?')
|
||||||
|
return $builder.Uri.AbsoluteUri
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Storage path utilities
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function Combine-StoragePath {
|
||||||
|
param(
|
||||||
|
[string]$Prefix,
|
||||||
|
[string]$Name
|
||||||
|
)
|
||||||
|
|
||||||
|
$cleanName = $Name.Replace('\', '/').TrimStart('/')
|
||||||
|
if ([string]::IsNullOrWhiteSpace($Prefix)) { return $cleanName }
|
||||||
|
$normalizedPrefix = $Prefix.Replace('\', '/').Trim('/')
|
||||||
|
if ([string]::IsNullOrEmpty($normalizedPrefix)) { return $cleanName }
|
||||||
|
return "$normalizedPrefix/$cleanName"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AWS SigV4 / S3 helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function Get-Bytes([string]$s) { return [System.Text.Encoding]::UTF8.GetBytes($s) }
|
||||||
|
|
||||||
|
function Get-HashHex([byte[]]$bytes) {
|
||||||
|
if ($null -eq $bytes) { $bytes = [byte[]]@() }
|
||||||
|
$sha = [System.Security.Cryptography.SHA256]::Create()
|
||||||
|
try {
|
||||||
|
$ms = New-Object System.IO.MemoryStream -ArgumentList (,$bytes)
|
||||||
|
try {
|
||||||
|
$hash = $sha.ComputeHash([System.IO.Stream]$ms)
|
||||||
|
} finally { $ms.Dispose() }
|
||||||
|
return ([BitConverter]::ToString($hash)).Replace('-', '').ToLowerInvariant()
|
||||||
|
} finally { $sha.Dispose() }
|
||||||
|
}
|
||||||
|
|
||||||
|
function HmacSha256([byte[]]$key, [string]$data) {
|
||||||
|
$h = [System.Security.Cryptography.HMACSHA256]::new($key)
|
||||||
|
try {
|
||||||
|
$b = [System.Text.Encoding]::UTF8.GetBytes($data)
|
||||||
|
$ms = New-Object System.IO.MemoryStream -ArgumentList (,$b)
|
||||||
|
try {
|
||||||
|
return $h.ComputeHash([System.IO.Stream]$ms)
|
||||||
|
} finally { $ms.Dispose() }
|
||||||
|
} finally { $h.Dispose() }
|
||||||
|
}
|
||||||
|
|
||||||
|
function GetSignatureKey([string]$secret, [string]$dateStamp, [string]$regionName, [string]$serviceName) {
|
||||||
|
$kDate = HmacSha256 (Get-Bytes ('AWS4' + $secret)) $dateStamp
|
||||||
|
$kRegion = HmacSha256 $kDate $regionName
|
||||||
|
$kService = HmacSha256 $kRegion $serviceName
|
||||||
|
HmacSha256 $kService 'aws4_request'
|
||||||
|
}
|
||||||
|
|
||||||
|
function UriEncode([string]$data, [bool]$encodeSlash) {
|
||||||
|
if ($null -eq $data) { return '' }
|
||||||
|
$enc = [System.Uri]::EscapeDataString($data)
|
||||||
|
if (-not $encodeSlash) { $enc = $enc -replace '%2F', '/' }
|
||||||
|
return $enc
|
||||||
|
}
|
||||||
|
|
||||||
|
function BuildCanonicalPath([System.Uri]$uri) {
|
||||||
|
$segments = $uri.AbsolutePath.Split('/')
|
||||||
|
$encoded = @()
|
||||||
|
foreach ($s in $segments) { $encoded += (UriEncode $s $false) }
|
||||||
|
$path = ($encoded -join '/')
|
||||||
|
if (-not $path.StartsWith('/')) { $path = '/' + $path }
|
||||||
|
return $path
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToHex([byte[]]$b) { ([BitConverter]::ToString($b)).Replace('-', '').ToLowerInvariant() }
|
||||||
|
|
||||||
|
function BuildAuthHeaders($method, [System.Uri]$uri, [string]$region, [string]$accessKey, [string]$secretKey, [string]$payloadHash) {
|
||||||
|
$algorithm = 'AWS4-HMAC-SHA256'
|
||||||
|
$timestamp = (Get-Date).ToUniversalTime()
|
||||||
|
$amzDate = $timestamp.ToString('yyyyMMddTHHmmssZ')
|
||||||
|
$dateStamp = $timestamp.ToString('yyyyMMdd')
|
||||||
|
$hostHeader = $uri.Host
|
||||||
|
if (-not $uri.IsDefaultPort) { $hostHeader = "${hostHeader}:$($uri.Port)" }
|
||||||
|
$canonicalUri = BuildCanonicalPath $uri
|
||||||
|
$canonicalQueryString = ''
|
||||||
|
$canonicalHeaders = "host:$hostHeader`n" + "x-amz-content-sha256:$payloadHash`n" + "x-amz-date:$amzDate`n"
|
||||||
|
$signedHeaders = 'host;x-amz-content-sha256;x-amz-date'
|
||||||
|
$canonicalRequest = "$method`n$canonicalUri`n$canonicalQueryString`n$canonicalHeaders`n$signedHeaders`n$payloadHash"
|
||||||
|
$credentialScope = "$dateStamp/$region/s3/aws4_request"
|
||||||
|
$stringToSign = "$algorithm`n$amzDate`n$credentialScope`n$((Get-HashHex (Get-Bytes $canonicalRequest)))"
|
||||||
|
$signingKey = GetSignatureKey $secretKey $dateStamp $region 's3'
|
||||||
|
$signature = ToHex (HmacSha256 $signingKey $stringToSign)
|
||||||
|
$authHeader = "$algorithm Credential=$accessKey/$credentialScope, SignedHeaders=$signedHeaders, Signature=$signature"
|
||||||
|
@{
|
||||||
|
'x-amz-date' = $amzDate
|
||||||
|
'x-amz-content-sha256' = $payloadHash
|
||||||
|
'Authorization' = $authHeader
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function BuildS3Uri([string]$endpointUrl, [string]$bucket, [string]$key, [bool]$forcePathStyle) {
|
||||||
|
$base = [System.Uri]$endpointUrl
|
||||||
|
$builder = [System.UriBuilder]::new($base)
|
||||||
|
$normalizedKey = $key.Replace('\', '/').TrimStart('/')
|
||||||
|
if ($forcePathStyle) {
|
||||||
|
$path = $builder.Path.TrimEnd('/')
|
||||||
|
if ([string]::IsNullOrEmpty($path)) { $path = '/' }
|
||||||
|
$builder.Path = ($path.TrimEnd('/') + '/' + $bucket + '/' + $normalizedKey)
|
||||||
|
} else {
|
||||||
|
$builder.Host = "$bucket." + $builder.Host
|
||||||
|
$path = $builder.Path.TrimEnd('/')
|
||||||
|
if ([string]::IsNullOrEmpty($path)) { $path = '/' }
|
||||||
|
$builder.Path = ($path.TrimEnd('/') + '/' + $normalizedKey)
|
||||||
|
}
|
||||||
|
return $builder.Uri
|
||||||
|
}
|
||||||
|
|
||||||
|
function Ensure-AWSS3Module {
|
||||||
|
try { $null = [Amazon.S3.AmazonS3Client]; return } catch {}
|
||||||
|
try { Import-Module -Name AWS.Tools.S3 -ErrorAction Stop; return } catch {}
|
||||||
|
try { Import-Module -Name AWSPowerShell.NetCore -ErrorAction Stop; return } catch {}
|
||||||
|
throw "AWS Tools for PowerShell not found. Install with: Install-Module AWS.Tools.S3 -Scope CurrentUser"
|
||||||
|
}
|
||||||
|
|
||||||
|
function New-S3Client {
|
||||||
|
param(
|
||||||
|
[string]$EndpointUrl,
|
||||||
|
[string]$Region,
|
||||||
|
[string]$AccessKeyId,
|
||||||
|
[string]$SecretAccessKey,
|
||||||
|
[bool]$ForcePathStyle = $true
|
||||||
|
)
|
||||||
|
Ensure-AWSS3Module
|
||||||
|
$creds = New-Object Amazon.Runtime.BasicAWSCredentials($AccessKeyId, $SecretAccessKey)
|
||||||
|
$cfg = New-Object Amazon.S3.AmazonS3Config
|
||||||
|
if ($EndpointUrl) { $cfg.ServiceURL = $EndpointUrl }
|
||||||
|
if ($Region) { try { $cfg.RegionEndpoint = [Amazon.RegionEndpoint]::GetBySystemName($Region) } catch {} }
|
||||||
|
$cfg.ForcePathStyle = [bool]$ForcePathStyle
|
||||||
|
return (New-Object Amazon.S3.AmazonS3Client($creds, $cfg))
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Active Directory credential and permission helpers
|
||||||
|
# (requires the ActiveDirectory module to be loaded before calling)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function Get-ValidatedADCredential {
|
||||||
|
param (
|
||||||
|
[Parameter(Mandatory)][string]$DomainName,
|
||||||
|
[Parameter(Mandatory)][string]$Server,
|
||||||
|
[int]$MaxAttempts = 3
|
||||||
|
)
|
||||||
|
|
||||||
|
for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) {
|
||||||
|
$credential = Get-Credential -Message "Enter AD credentials with replication rights for $DomainName (attempt $attempt/$MaxAttempts)"
|
||||||
|
if ($null -eq $credential) {
|
||||||
|
throw "Credential prompt was cancelled."
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
Get-ADDomain -Server $Server -Credential $credential -ErrorAction Stop | Out-Null
|
||||||
|
Write-Verbose ("Credential pre-check succeeded for '{0}' against '{1}'." -f $credential.UserName, $Server)
|
||||||
|
return $credential
|
||||||
|
} catch {
|
||||||
|
$message = $_.Exception.Message
|
||||||
|
if ($message -match 'rejected the client credentials|unknown user name|bad password|logon failure') {
|
||||||
|
Write-Warning ("Credentials were rejected for '{0}' (attempt {1}/{2})." -f $credential.UserName, $attempt, $MaxAttempts)
|
||||||
|
if ($attempt -lt $MaxAttempts) { continue }
|
||||||
|
throw "Credentials were rejected by domain controller '$Server' after $MaxAttempts attempts."
|
||||||
|
}
|
||||||
|
throw "Credential pre-check failed against '$Server': $message"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Test-ReplicationPermissions {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)][string]$DomainDN,
|
||||||
|
[Parameter(Mandatory)][string]$Server,
|
||||||
|
[Parameter(Mandatory)][System.Management.Automation.PSCredential]$Credential
|
||||||
|
)
|
||||||
|
|
||||||
|
$requiredRights = [ordered]@{
|
||||||
|
'Replicating Directory Changes' = [guid]'1131f6aa-9c07-11d1-f79f-00c04fc2dcd2'
|
||||||
|
'Replicating Directory Changes All' = [guid]'1131f6ab-9c07-11d1-f79f-00c04fc2dcd2'
|
||||||
|
'Replicating Directory Changes In Filtered Set' = [guid]'89e95b76-444d-4c62-991a-0facbeda640c'
|
||||||
|
}
|
||||||
|
|
||||||
|
$callerSids = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
|
||||||
|
try {
|
||||||
|
$samName = $Credential.UserName -replace '^.*\\', ''
|
||||||
|
$adUser = Get-ADUser -Identity $samName -Server $Server -Credential $Credential `
|
||||||
|
-Properties SID, DistinguishedName, 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)
|
||||||
|
[void]$callerSids.Add($sid.Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
# adminCount=1 means SDProp is managing this account; it runs every 60 min and can
|
||||||
|
# silently revert replication rights or group memberships granted to the account
|
||||||
|
if ($adUser.adminCount -eq 1) {
|
||||||
|
Write-Warning ("Account '{0}' has adminCount=1 (SDProp-protected). It is or was a member of a privileged group. SDProp runs every 60 minutes and may silently revert replication rights or group memberships on this account." -f $Credential.UserName)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Protected Users group (RID 525) blocks the Kerberos mechanisms DSInternals uses for DRS
|
||||||
|
$domainSidStr = $adUser.SID.Value.Substring(0, $adUser.SID.Value.LastIndexOf('-'))
|
||||||
|
$protectedUsersSid = "$domainSidStr-525"
|
||||||
|
if ($callerSids.Contains($protectedUsersSid)) {
|
||||||
|
Write-Warning ("Account '{0}' is a member of Protected Users. This group restricts Kerberos delegation and RC4 authentication that DSInternals requires for DRS replication - access will be denied regardless of assigned rights." -f $Credential.UserName)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Write-Warning ("Could not resolve account SIDs for replication permission pre-check: {0}. Skipping." -f $_.Exception.Message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
$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
|
||||||
|
}
|
||||||
|
|
||||||
|
$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 }
|
||||||
|
}
|
||||||
|
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)'
|
||||||
|
}
|
||||||
|
$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 - "))
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ("[+] Replication permissions verified for '{0}'." -f $Credential.UserName)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Test-DCClockSkew {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)][string]$Server,
|
||||||
|
[Parameter(Mandatory)][System.Management.Automation.PSCredential]$Credential
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
$rootDse = New-Object System.DirectoryServices.DirectoryEntry(
|
||||||
|
"LDAP://$Server/RootDSE",
|
||||||
|
$Credential.UserName,
|
||||||
|
$Credential.GetNetworkCredential().Password
|
||||||
|
)
|
||||||
|
$dcTimeStr = $rootDse.Properties['currentTime'][0]
|
||||||
|
$dcTime = [datetime]::ParseExact(
|
||||||
|
$dcTimeStr, 'yyyyMMddHHmmss.0Z',
|
||||||
|
[System.Globalization.CultureInfo]::InvariantCulture,
|
||||||
|
[System.Globalization.DateTimeStyles]::AssumeUniversal).ToUniversalTime()
|
||||||
|
$skewSeconds = [Math]::Abs(([datetime]::UtcNow - $dcTime).TotalSeconds)
|
||||||
|
if ($skewSeconds -gt 300) {
|
||||||
|
Write-Warning ("Clock skew of {0:N0}s with '{1}' exceeds Kerberos limit of 300s - authentication will fail. Sync the clock: w32tm /resync /force" -f $skewSeconds, $Server)
|
||||||
|
} elseif ($skewSeconds -gt 60) {
|
||||||
|
Write-Warning ("Clock skew of {0:N0}s detected with '{1}'. Kerberos allows up to 300s - approaching the limit." -f $skewSeconds, $Server)
|
||||||
|
} else {
|
||||||
|
Write-Host ("[+] Clock skew with '{0}': {1:N0}s (OK)." -f $Server, $skewSeconds)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Write-Warning ("Could not check clock skew against '{0}': {1}" -f $Server, $_.Exception.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+1
-3
@@ -7,7 +7,7 @@
|
|||||||
##################################################
|
##################################################
|
||||||
## Project: Elysium ##
|
## Project: Elysium ##
|
||||||
## File: Elysium.ps1 ##
|
## File: Elysium.ps1 ##
|
||||||
## Version: 2.2.0 ##
|
## Version: 2.4.2 ##
|
||||||
## Support: support@cqre.net ##
|
## Support: support@cqre.net ##
|
||||||
##################################################
|
##################################################
|
||||||
|
|
||||||
@@ -52,8 +52,6 @@ if ([string]::IsNullOrEmpty($passphrase)) {
|
|||||||
Write-Host "Passphrase found in environment variables."
|
Write-Host "Passphrase found in environment variables."
|
||||||
}
|
}
|
||||||
|
|
||||||
# Continue with the rest of your script...
|
|
||||||
|
|
||||||
function Start-OrchestratorTranscript {
|
function Start-OrchestratorTranscript {
|
||||||
param([string]$BasePath)
|
param([string]$BasePath)
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
##################################################
|
##################################################
|
||||||
## Project: Elysium ##
|
## Project: Elysium ##
|
||||||
## File: ElysiumSettings.txt ##
|
## File: ElysiumSettings.txt ##
|
||||||
## Version: 2.2.0 ##
|
## Version: 2.4.2 ##
|
||||||
## Support: support@cqre.net ##
|
## Support: support@cqre.net ##
|
||||||
##################################################
|
##################################################
|
||||||
|
|
||||||
|
|||||||
+173
-275
@@ -6,8 +6,8 @@
|
|||||||
## \____\__\_\_| \_\_____(_)_| \_|_____| |_| ##
|
## \____\__\_\_| \_\_____(_)_| \_|_____| |_| ##
|
||||||
##################################################
|
##################################################
|
||||||
## Project: Elysium ##
|
## Project: Elysium ##
|
||||||
## File: Extract-NTLMHashes.ps1 ##
|
## File: Extract-NTHashes.ps1 ##
|
||||||
## Version: 2.2.0 ##
|
## Version: 2.4.2 ##
|
||||||
## Support: support@cqre.net ##
|
## Support: support@cqre.net ##
|
||||||
##################################################
|
##################################################
|
||||||
|
|
||||||
@@ -25,6 +25,11 @@ Set-StrictMode -Version Latest
|
|||||||
|
|
||||||
$scriptRoot = $PSScriptRoot
|
$scriptRoot = $PSScriptRoot
|
||||||
|
|
||||||
|
[string]$commonHelper = Join-Path -Path $PSScriptRoot -ChildPath 'Elysium.Common.ps1'
|
||||||
|
if (-not (Test-Path -LiteralPath $commonHelper)) { throw "Common helper not found at $commonHelper" }
|
||||||
|
. $commonHelper
|
||||||
|
Restart-WithWindowsPowerShellIfAvailable -BoundParameters $PSBoundParameters -UnboundArguments $MyInvocation.UnboundArguments
|
||||||
|
|
||||||
function Start-ExtractTranscript {
|
function Start-ExtractTranscript {
|
||||||
param([string]$BasePath)
|
param([string]$BasePath)
|
||||||
try {
|
try {
|
||||||
@@ -40,167 +45,18 @@ function Start-ExtractTranscript {
|
|||||||
|
|
||||||
function Stop-ExtractTranscript { try { Stop-Transcript | Out-Null } catch {} }
|
function Stop-ExtractTranscript { try { Stop-Transcript | Out-Null } catch {} }
|
||||||
|
|
||||||
Start-ExtractTranscript -BasePath $scriptRoot
|
|
||||||
try {
|
|
||||||
# Import settings
|
|
||||||
Write-Host "Loading settings..."
|
|
||||||
$ElysiumSettings = @{}
|
|
||||||
$settingsPath = Join-Path -Path $scriptRoot -ChildPath "ElysiumSettings.txt"
|
|
||||||
|
|
||||||
if (-not (Test-Path $settingsPath)) {
|
|
||||||
Write-Error "Settings file not found at $settingsPath"
|
|
||||||
exit
|
|
||||||
}
|
|
||||||
|
|
||||||
Get-Content $settingsPath | ForEach-Object {
|
|
||||||
if (-not [string]::IsNullOrWhiteSpace($_) -and -not $_.StartsWith("#")) {
|
|
||||||
$keyValue = $_ -split '=', 2
|
|
||||||
if ($keyValue.Count -eq 2) {
|
|
||||||
$ElysiumSettings[$keyValue[0].Trim()] = $keyValue[1].Trim()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function Normalize-ReportPath([string]$p) {
|
function Normalize-ReportPath([string]$p) {
|
||||||
if ([string]::IsNullOrWhiteSpace($p)) { return (Join-Path -Path $scriptRoot -ChildPath 'Reports') }
|
if ([string]::IsNullOrWhiteSpace($p)) { return (Join-Path -Path $scriptRoot -ChildPath 'Reports') }
|
||||||
if ([System.IO.Path]::IsPathRooted($p)) { return $p }
|
if ([System.IO.Path]::IsPathRooted($p)) { return $p }
|
||||||
return (Join-Path -Path $scriptRoot -ChildPath $p)
|
return (Join-Path -Path $scriptRoot -ChildPath $p)
|
||||||
}
|
}
|
||||||
|
|
||||||
# Storage provider selection (Azure by default)
|
|
||||||
$storageProvider = $ElysiumSettings['StorageProvider']
|
|
||||||
if ([string]::IsNullOrWhiteSpace($storageProvider)) { $storageProvider = 'Azure' }
|
|
||||||
|
|
||||||
# Azure settings
|
|
||||||
$storageAccountName = $ElysiumSettings['storageAccountName']
|
|
||||||
$containerName = $ElysiumSettings['containerName']
|
|
||||||
$sasToken = $ElysiumSettings['sasToken']
|
|
||||||
|
|
||||||
# S3-compatible settings
|
|
||||||
$s3EndpointUrl = $ElysiumSettings['s3EndpointUrl']
|
|
||||||
$s3Region = $ElysiumSettings['s3Region']
|
|
||||||
$s3BucketName = $ElysiumSettings['s3BucketName']
|
|
||||||
$s3AccessKeyId = $ElysiumSettings['s3AccessKeyId']
|
|
||||||
$s3SecretAccessKey = $ElysiumSettings['s3SecretAccessKey']
|
|
||||||
$s3ForcePathStyle = $ElysiumSettings['s3ForcePathStyle']
|
|
||||||
$s3UseAwsTools = $ElysiumSettings['s3UseAwsTools']
|
|
||||||
if ([string]::IsNullOrWhiteSpace($s3Region)) { $s3Region = 'us-east-1' }
|
|
||||||
try { $s3ForcePathStyle = [System.Convert]::ToBoolean($s3ForcePathStyle) } catch { $s3ForcePathStyle = $true }
|
|
||||||
try { $s3UseAwsTools = [System.Convert]::ToBoolean($s3UseAwsTools) } catch { $s3UseAwsTools = $false }
|
|
||||||
|
|
||||||
function Ensure-AWSS3Module {
|
|
||||||
# Ensure AWS SDK types are available via AWS Tools for PowerShell
|
|
||||||
try {
|
|
||||||
$null = [Amazon.S3.AmazonS3Client]
|
|
||||||
return
|
|
||||||
} catch {
|
|
||||||
try { Import-Module -Name AWS.Tools.S3 -ErrorAction Stop; return } catch {}
|
|
||||||
try { Import-Module -Name AWSPowerShell.NetCore -ErrorAction Stop; return } catch {}
|
|
||||||
throw "AWS Tools for PowerShell not found. Install with: Install-Module AWS.Tools.S3 -Scope CurrentUser"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function New-S3Client {
|
|
||||||
param(
|
|
||||||
[string]$EndpointUrl,
|
|
||||||
[string]$Region,
|
|
||||||
[string]$AccessKeyId,
|
|
||||||
[string]$SecretAccessKey,
|
|
||||||
[bool]$ForcePathStyle = $true
|
|
||||||
)
|
|
||||||
Ensure-AWSS3Module
|
|
||||||
$creds = New-Object Amazon.Runtime.BasicAWSCredentials($AccessKeyId, $SecretAccessKey)
|
|
||||||
$cfg = New-Object Amazon.S3.AmazonS3Config
|
|
||||||
if ($EndpointUrl) { $cfg.ServiceURL = $EndpointUrl }
|
|
||||||
if ($Region) {
|
|
||||||
try { $cfg.RegionEndpoint = [Amazon.RegionEndpoint]::GetBySystemName($Region) } catch {}
|
|
||||||
}
|
|
||||||
$cfg.ForcePathStyle = [bool]$ForcePathStyle
|
|
||||||
return (New-Object Amazon.S3.AmazonS3Client($creds, $cfg))
|
|
||||||
}
|
|
||||||
|
|
||||||
# Native S3 SigV4 (no AWS Tools) helpers
|
|
||||||
function Get-Bytes([string]$s) { return [System.Text.Encoding]::UTF8.GetBytes($s) }
|
|
||||||
function Get-HashHex([byte[]]$bytes) {
|
|
||||||
$sha = [System.Security.Cryptography.SHA256]::Create()
|
|
||||||
try {
|
|
||||||
if ($null -eq $bytes) { $bytes = [byte[]]@() }
|
|
||||||
$ms = [System.IO.MemoryStream]::new($bytes)
|
|
||||||
try {
|
|
||||||
$hashBytes = $sha.ComputeHash($ms)
|
|
||||||
return ([BitConverter]::ToString($hashBytes)).Replace('-', '').ToLowerInvariant()
|
|
||||||
} finally { $ms.Dispose() }
|
|
||||||
} finally { $sha.Dispose() }
|
|
||||||
}
|
|
||||||
function Get-FileSha256Hex([string]$path) {
|
function Get-FileSha256Hex([string]$path) {
|
||||||
$sha = [System.Security.Cryptography.SHA256]::Create()
|
$sha = [System.Security.Cryptography.SHA256]::Create()
|
||||||
$fs = [System.IO.File]::OpenRead($path)
|
$fs = [System.IO.File]::OpenRead($path)
|
||||||
try { return ([BitConverter]::ToString($sha.ComputeHash($fs))).Replace('-', '').ToLowerInvariant() } finally { $fs.Close(); $sha.Dispose() }
|
try { return ([BitConverter]::ToString($sha.ComputeHash($fs))).Replace('-', '').ToLowerInvariant() } finally { $fs.Close(); $sha.Dispose() }
|
||||||
}
|
}
|
||||||
function HmacSha256([byte[]]$key, [string]$data) {
|
|
||||||
$h = [System.Security.Cryptography.HMACSHA256]::new($key)
|
|
||||||
try {
|
|
||||||
$dataBytes = Get-Bytes $data
|
|
||||||
$ms = [System.IO.MemoryStream]::new($dataBytes)
|
|
||||||
try { return $h.ComputeHash($ms) } finally { $ms.Dispose() }
|
|
||||||
} finally { $h.Dispose() }
|
|
||||||
}
|
|
||||||
function GetSignatureKey([string]$secret, [string]$dateStamp, [string]$regionName, [string]$serviceName) {
|
|
||||||
$kDate = HmacSha256 (Get-Bytes ('AWS4' + $secret)) $dateStamp
|
|
||||||
$kRegion = HmacSha256 $kDate $regionName
|
|
||||||
$kService = HmacSha256 $kRegion $serviceName
|
|
||||||
return (HmacSha256 $kService 'aws4_request')
|
|
||||||
}
|
|
||||||
function UriEncode([string]$data, [bool]$encodeSlash) {
|
|
||||||
if ($null -eq $data) { return '' }
|
|
||||||
$enc = [System.Uri]::EscapeDataString($data)
|
|
||||||
if (-not $encodeSlash) { $enc = $enc -replace '%2F','/' }
|
|
||||||
return $enc
|
|
||||||
}
|
|
||||||
function BuildCanonicalPath([System.Uri]$uri) {
|
|
||||||
$segments = $uri.AbsolutePath.Split('/')
|
|
||||||
$encoded = @()
|
|
||||||
foreach ($seg in $segments) { $encoded += (UriEncode $seg $false) }
|
|
||||||
$path = ($encoded -join '/')
|
|
||||||
if (-not $path.StartsWith('/')) { $path = '/' + $path }
|
|
||||||
return $path
|
|
||||||
}
|
|
||||||
function ToHex([byte[]]$bytes) { return ([BitConverter]::ToString($bytes)).Replace('-', '').ToLowerInvariant() }
|
|
||||||
function BuildAuthHeaders($method, [System.Uri]$uri, [string]$region, [string]$accessKey, [string]$secretKey, [string]$payloadHash) {
|
|
||||||
$algorithm = 'AWS4-HMAC-SHA256'
|
|
||||||
$amzdate = (Get-Date).ToUniversalTime().ToString('yyyyMMddTHHmmssZ')
|
|
||||||
$datestamp = (Get-Date).ToUniversalTime().ToString('yyyyMMdd')
|
|
||||||
$hostHeader = $uri.Host
|
|
||||||
if (-not $uri.IsDefaultPort) { $hostHeader = "{0}:{1}" -f $hostHeader, $uri.Port }
|
|
||||||
|
|
||||||
$canonicalUri = BuildCanonicalPath $uri
|
|
||||||
$canonicalQueryString = ''
|
|
||||||
$canonicalHeaders = "host:$hostHeader`n" + "x-amz-content-sha256:$payloadHash`n" + "x-amz-date:$amzdate`n"
|
|
||||||
$signedHeaders = 'host;x-amz-content-sha256;x-amz-date'
|
|
||||||
$canonicalRequest = "$method`n$canonicalUri`n$canonicalQueryString`n$canonicalHeaders`n$signedHeaders`n$payloadHash"
|
|
||||||
|
|
||||||
$credentialScope = "$datestamp/$region/s3/aws4_request"
|
|
||||||
$stringToSign = "$algorithm`n$amzdate`n$credentialScope`n$((Get-HashHex (Get-Bytes $canonicalRequest)))"
|
|
||||||
$signingKey = GetSignatureKey $secretKey $datestamp $region 's3'
|
|
||||||
$signature = ToHex (HmacSha256 $signingKey $stringToSign)
|
|
||||||
$authHeader = "$algorithm Credential=$accessKey/$credentialScope, SignedHeaders=$signedHeaders, Signature=$signature"
|
|
||||||
return @{ 'x-amz-date' = $amzdate; 'x-amz-content-sha256' = $payloadHash; 'Authorization' = $authHeader }
|
|
||||||
}
|
|
||||||
function BuildS3Uri([string]$endpointUrl, [string]$bucket, [string]$key, [bool]$forcePathStyle) {
|
|
||||||
$base = [System.Uri]$endpointUrl
|
|
||||||
$ub = [System.UriBuilder]::new($base)
|
|
||||||
if ($forcePathStyle) {
|
|
||||||
$p = ($ub.Path.TrimEnd('/'))
|
|
||||||
if ([string]::IsNullOrEmpty($p)) { $p = '/' }
|
|
||||||
$ub.Path = ($p.TrimEnd('/') + '/' + $bucket + '/' + $key)
|
|
||||||
} else {
|
|
||||||
$ub.Host = "$bucket." + $ub.Host
|
|
||||||
$p = $ub.Path.TrimEnd('/')
|
|
||||||
if ([string]::IsNullOrEmpty($p)) { $p = '/' }
|
|
||||||
$ub.Path = ($p.TrimEnd('/') + '/' + $key)
|
|
||||||
}
|
|
||||||
return $ub.Uri
|
|
||||||
}
|
|
||||||
function Invoke-S3PutFile([string]$endpointUrl, [string]$bucket, [string]$key, [string]$filePath, [string]$region, [string]$ak, [string]$sk, [bool]$forcePathStyle) {
|
function Invoke-S3PutFile([string]$endpointUrl, [string]$bucket, [string]$key, [string]$filePath, [string]$region, [string]$ak, [string]$sk, [bool]$forcePathStyle) {
|
||||||
$uri = BuildS3Uri -endpointUrl $endpointUrl -bucket $bucket -key $key -forcePathStyle $forcePathStyle
|
$uri = BuildS3Uri -endpointUrl $endpointUrl -bucket $bucket -key $key -forcePathStyle $forcePathStyle
|
||||||
$payloadHash = Get-FileSha256Hex -path $filePath
|
$payloadHash = Get-FileSha256Hex -path $filePath
|
||||||
@@ -218,6 +74,7 @@ function Invoke-S3PutFile([string]$endpointUrl, [string]$bucket, [string]$key, [
|
|||||||
if (-not $resp.IsSuccessStatusCode) { throw "S3 PUT failed: $([int]$resp.StatusCode) $($resp.ReasonPhrase)" }
|
if (-not $resp.IsSuccessStatusCode) { throw "S3 PUT failed: $([int]$resp.StatusCode) $($resp.ReasonPhrase)" }
|
||||||
} finally { if ($req) { $req.Dispose() }; if ($stream) { $stream.Close(); $stream.Dispose() }; $client.Dispose() }
|
} finally { if ($req) { $req.Dispose() }; if ($stream) { $stream.Close(); $stream.Dispose() }; $client.Dispose() }
|
||||||
}
|
}
|
||||||
|
|
||||||
function Invoke-S3GetToFile([string]$endpointUrl, [string]$bucket, [string]$key, [string]$targetPath, [string]$region, [string]$ak, [string]$sk, [bool]$forcePathStyle) {
|
function Invoke-S3GetToFile([string]$endpointUrl, [string]$bucket, [string]$key, [string]$targetPath, [string]$region, [string]$ak, [string]$sk, [bool]$forcePathStyle) {
|
||||||
$uri = BuildS3Uri -endpointUrl $endpointUrl -bucket $bucket -key $key -forcePathStyle $forcePathStyle
|
$uri = BuildS3Uri -endpointUrl $endpointUrl -bucket $bucket -key $key -forcePathStyle $forcePathStyle
|
||||||
$payloadHash = (Get-HashHex (Get-Bytes ''))
|
$payloadHash = (Get-HashHex (Get-Bytes ''))
|
||||||
@@ -236,13 +93,6 @@ function Invoke-S3GetToFile([string]$endpointUrl, [string]$bucket, [string]$key,
|
|||||||
} finally { if ($req) { $req.Dispose() }; $client.Dispose() }
|
} finally { if ($req) { $req.Dispose() }; $client.Dispose() }
|
||||||
}
|
}
|
||||||
|
|
||||||
# Retrieve the passphrase from a user environment variable
|
|
||||||
$passphrase = [System.Environment]::GetEnvironmentVariable("ELYSIUM_PASSPHRASE", [System.EnvironmentVariableTarget]::User)
|
|
||||||
if ([string]::IsNullOrWhiteSpace($passphrase)) { Write-Error 'Passphrase not found in ELYSIUM_PASSPHRASE environment variable.'; exit }
|
|
||||||
|
|
||||||
# Timestamp
|
|
||||||
$timestamp = Get-Date -Format "yyyyMMdd-HHmmss"
|
|
||||||
|
|
||||||
function Protect-FileWithAES {
|
function Protect-FileWithAES {
|
||||||
param (
|
param (
|
||||||
[Parameter(Mandatory = $true)]
|
[Parameter(Mandatory = $true)]
|
||||||
@@ -255,7 +105,6 @@ function Protect-FileWithAES {
|
|||||||
[string]$Passphrase
|
[string]$Passphrase
|
||||||
)
|
)
|
||||||
|
|
||||||
# Derive key with PBKDF2 (HMACSHA256) + random salt
|
|
||||||
$rng = [System.Security.Cryptography.RandomNumberGenerator]::Create()
|
$rng = [System.Security.Cryptography.RandomNumberGenerator]::Create()
|
||||||
$salt = New-Object byte[] 16
|
$salt = New-Object byte[] 16
|
||||||
$rng.GetBytes($salt)
|
$rng.GetBytes($salt)
|
||||||
@@ -277,7 +126,6 @@ function Protect-FileWithAES {
|
|||||||
$outFileStream = [System.IO.File]::Create($OutputFile)
|
$outFileStream = [System.IO.File]::Create($OutputFile)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
# File header: magic 'ELY1' (4 bytes), salt (16 bytes), IV (16 bytes)
|
|
||||||
$magic = [System.Text.Encoding]::ASCII.GetBytes('ELY1')
|
$magic = [System.Text.Encoding]::ASCII.GetBytes('ELY1')
|
||||||
$outFileStream.Write($magic, 0, $magic.Length)
|
$outFileStream.Write($magic, 0, $magic.Length)
|
||||||
$outFileStream.Write($salt, 0, $salt.Length)
|
$outFileStream.Write($salt, 0, $salt.Length)
|
||||||
@@ -299,6 +147,7 @@ function Protect-FileWithAES {
|
|||||||
|
|
||||||
Write-Host "File has been encrypted (PBKDF2+AES-256-CBC): $OutputFile"
|
Write-Host "File has been encrypted (PBKDF2+AES-256-CBC): $OutputFile"
|
||||||
}
|
}
|
||||||
|
|
||||||
function Get-FileChecksum {
|
function Get-FileChecksum {
|
||||||
param (
|
param (
|
||||||
[string]$Path,
|
[string]$Path,
|
||||||
@@ -309,142 +158,191 @@ function Get-FileChecksum {
|
|||||||
try {
|
try {
|
||||||
$hashBytes = $hasher.ComputeHash($stream)
|
$hashBytes = $hasher.ComputeHash($stream)
|
||||||
return [BitConverter]::ToString($hashBytes) -replace '-', ''
|
return [BitConverter]::ToString($hashBytes) -replace '-', ''
|
||||||
}
|
} finally {
|
||||||
finally {
|
|
||||||
$stream.Close()
|
$stream.Close()
|
||||||
$hasher.Dispose()
|
$hasher.Dispose()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# Extract NTLM hashes
|
Start-ExtractTranscript -BasePath $scriptRoot
|
||||||
$reportBase = Normalize-ReportPath -p $ElysiumSettings['ReportPathBase']
|
try {
|
||||||
if (-not (Test-Path $reportBase)) { New-Item -Path $reportBase -ItemType Directory -Force | Out-Null }
|
Write-Host "Loading settings..."
|
||||||
|
$ElysiumSettings = Read-ElysiumSettings -ScriptRoot $scriptRoot
|
||||||
|
|
||||||
# Build domain details from settings (ordered to keep numeric index order)
|
# Storage provider selection (Azure by default)
|
||||||
$DomainDetails = [ordered]@{}
|
$storageProvider = $ElysiumSettings['StorageProvider']
|
||||||
for ($i = 1; $ElysiumSettings.ContainsKey("Domain${i}Name"); $i++) {
|
if ([string]::IsNullOrWhiteSpace($storageProvider)) { $storageProvider = 'Azure' }
|
||||||
$DomainDetails["$i"] = @{
|
|
||||||
Name = $ElysiumSettings["Domain${i}Name"]
|
|
||||||
DC = $ElysiumSettings["Domain${i}DC"]
|
|
||||||
DA = $ElysiumSettings["Domain${i}DA"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# User selects a domain
|
# Azure settings
|
||||||
Write-Host "Select a domain to extract NTLM hashes:"
|
$storageAccountName = $ElysiumSettings['storageAccountName']
|
||||||
$DomainDetails.GetEnumerator() | Sort-Object { [int]$_.Key } | ForEach-Object { Write-Host "$($_.Key): $($_.Value.Name)" }
|
$containerName = $ElysiumSettings['containerName']
|
||||||
$selection = Read-Host "Enter the number of the domain"
|
$sasToken = $ElysiumSettings['sasToken']
|
||||||
$selectedDomain = $DomainDetails[$selection]
|
|
||||||
|
|
||||||
if (-not $selectedDomain) {
|
# S3-compatible settings
|
||||||
Write-Error "Invalid selection."
|
$s3EndpointUrl = $ElysiumSettings['s3EndpointUrl']
|
||||||
exit
|
$s3Region = $ElysiumSettings['s3Region']
|
||||||
}
|
$s3BucketName = $ElysiumSettings['s3BucketName']
|
||||||
|
$s3AccessKeyId = $ElysiumSettings['s3AccessKeyId']
|
||||||
|
$s3SecretAccessKey = $ElysiumSettings['s3SecretAccessKey']
|
||||||
|
$s3ForcePathStyle = $ElysiumSettings['s3ForcePathStyle']
|
||||||
|
$s3UseAwsTools = $ElysiumSettings['s3UseAwsTools']
|
||||||
|
if ([string]::IsNullOrWhiteSpace($s3Region)) { $s3Region = 'us-east-1' }
|
||||||
|
try { $s3ForcePathStyle = [System.Convert]::ToBoolean($s3ForcePathStyle) } catch { $s3ForcePathStyle = $true }
|
||||||
|
try { $s3UseAwsTools = [System.Convert]::ToBoolean($s3UseAwsTools) } catch { $s3UseAwsTools = $false }
|
||||||
|
|
||||||
# Update script variables based on selected domain
|
# Retrieve the passphrase from a user environment variable
|
||||||
$domainController = $selectedDomain.DC
|
$passphrase = [System.Environment]::GetEnvironmentVariable("ELYSIUM_PASSPHRASE", [System.EnvironmentVariableTarget]::User)
|
||||||
$credential = Get-Credential -Message "Enter AD credentials with replication rights for $($selectedDomain.Name)"
|
if ([string]::IsNullOrWhiteSpace($passphrase)) { throw 'Passphrase not found in ELYSIUM_PASSPHRASE environment variable.' }
|
||||||
|
|
||||||
$domainPrefix = ($selectedDomain.Name -replace "\W", "_")
|
$timestamp = Get-Date -Format "yyyyMMdd-HHmmss"
|
||||||
$baseName = "${domainPrefix}_NTLM_Hashes_$timestamp"
|
|
||||||
$exportPath = Join-Path -Path $scriptRoot -ChildPath "$baseName.txt"
|
|
||||||
$compressedFilePath = Join-Path -Path $scriptRoot -ChildPath "$baseName.zip"
|
|
||||||
$encryptedFilePath = Join-Path -Path $scriptRoot -ChildPath "$baseName.enc"
|
|
||||||
$blobName = "$baseName.enc"
|
|
||||||
|
|
||||||
$ntlmHashes = Get-ADReplAccount -All -Server $domainController -Credential $credential |
|
$reportBase = Normalize-ReportPath -p $ElysiumSettings['ReportPathBase']
|
||||||
Where-Object { $_.NTHash } |
|
if (-not (Test-Path $reportBase)) { New-Item -Path $reportBase -ItemType Directory -Force | Out-Null }
|
||||||
ForEach-Object { [BitConverter]::ToString($_.NTHash).Replace("-", "") } |
|
|
||||||
Sort-Object -Unique
|
|
||||||
|
|
||||||
$ntlmHashes | Out-File -FilePath $exportPath
|
# Build domain details from settings (ordered to keep numeric index order)
|
||||||
Write-Host "NTLM hashes have been extracted to: $exportPath"
|
$DomainDetails = [ordered]@{}
|
||||||
|
for ($i = 1; $ElysiumSettings.ContainsKey("Domain${i}Name"); $i++) {
|
||||||
# Compress extracted NTLM hashes
|
$DomainDetails["$i"] = @{
|
||||||
Compress-Archive -Path $exportPath -DestinationPath $compressedFilePath
|
Name = $ElysiumSettings["Domain${i}Name"]
|
||||||
Write-Host "File has been compressed: $compressedFilePath"
|
DC = $ElysiumSettings["Domain${i}DC"]
|
||||||
|
DA = $ElysiumSettings["Domain${i}DA"]
|
||||||
# Encrypt the compressed file
|
|
||||||
Protect-FileWithAES -InputFile $compressedFilePath -OutputFile $encryptedFilePath -Passphrase $passphrase
|
|
||||||
Write-Host "File has been encrypted: $encryptedFilePath"
|
|
||||||
|
|
||||||
# Calculate the local file checksum
|
|
||||||
$localFileChecksum = Get-FileChecksum -Path $encryptedFilePath
|
|
||||||
|
|
||||||
if ($storageProvider -ieq 'S3') {
|
|
||||||
# S3-compatible path (e.g., IDrive e2) without requiring AWS Tools
|
|
||||||
if ([string]::IsNullOrWhiteSpace($s3BucketName)) { Write-Error 's3BucketName is missing in settings.'; exit }
|
|
||||||
if ([string]::IsNullOrWhiteSpace($s3AccessKeyId) -or [string]::IsNullOrWhiteSpace($s3SecretAccessKey)) { Write-Error 's3AccessKeyId / s3SecretAccessKey missing in settings.'; exit }
|
|
||||||
if ([string]::IsNullOrWhiteSpace($s3EndpointUrl)) { Write-Error 's3EndpointUrl is required for S3-compatible storage.'; exit }
|
|
||||||
|
|
||||||
$usedAwsTools = $false
|
|
||||||
if ($s3UseAwsTools) {
|
|
||||||
try {
|
|
||||||
$s3Client = New-S3Client -EndpointUrl $s3EndpointUrl -Region $s3Region -AccessKeyId $s3AccessKeyId -SecretAccessKey $s3SecretAccessKey -ForcePathStyle:$s3ForcePathStyle
|
|
||||||
# Upload
|
|
||||||
$putReq = New-Object Amazon.S3.Model.PutObjectRequest -Property @{ BucketName = $s3BucketName; Key = $blobName; FilePath = $encryptedFilePath }
|
|
||||||
$null = $s3Client.PutObject($putReq)
|
|
||||||
Write-Host "Encrypted file uploaded to S3-compatible bucket (AWS Tools): $blobName"
|
|
||||||
$tempDownloadPath = [System.IO.Path]::GetTempFileName()
|
|
||||||
$getReq = New-Object Amazon.S3.Model.GetObjectRequest -Property @{ BucketName = $s3BucketName; Key = $blobName }
|
|
||||||
$getResp = $s3Client.GetObject($getReq)
|
|
||||||
$getResp.WriteResponseStreamToFile($tempDownloadPath, $true)
|
|
||||||
$getResp.Dispose()
|
|
||||||
$downloadedFileChecksum = Get-FileChecksum -Path $tempDownloadPath
|
|
||||||
$usedAwsTools = $true
|
|
||||||
} catch {
|
|
||||||
Write-Warning "AWS Tools path failed or not available. Falling back to native HTTP (SigV4). Details: $($_.Exception.Message)"
|
|
||||||
$usedAwsTools = $false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (-not $usedAwsTools) {
|
# User selects a domain
|
||||||
Invoke-S3PutFile -endpointUrl $s3EndpointUrl -bucket $s3BucketName -key $blobName -filePath $encryptedFilePath -region $s3Region -ak $s3AccessKeyId -sk $s3SecretAccessKey -forcePathStyle:$s3ForcePathStyle
|
Write-Host "Select a domain to extract NTLM hashes:"
|
||||||
Write-Host "Encrypted file uploaded to S3-compatible bucket: $blobName"
|
$DomainDetails.GetEnumerator() | Sort-Object { [int]$_.Key } | ForEach-Object { Write-Host "$($_.Key): $($_.Value.Name)" }
|
||||||
$tempDownloadPath = [System.IO.Path]::GetTempFileName()
|
$selection = Read-Host "Enter the number of the domain"
|
||||||
Invoke-S3GetToFile -endpointUrl $s3EndpointUrl -bucket $s3BucketName -key $blobName -targetPath $tempDownloadPath -region $s3Region -ak $s3AccessKeyId -sk $s3SecretAccessKey -forcePathStyle:$s3ForcePathStyle
|
$selectedDomain = $DomainDetails[$selection]
|
||||||
$downloadedFileChecksum = Get-FileChecksum -Path $tempDownloadPath
|
|
||||||
|
if (-not $selectedDomain) {
|
||||||
|
throw "Invalid selection."
|
||||||
}
|
}
|
||||||
}
|
|
||||||
else {
|
|
||||||
# Azure Blob Storage path (default)
|
|
||||||
$sas = $sasToken
|
|
||||||
if ([string]::IsNullOrWhiteSpace($sas)) { Write-Error 'sasToken is missing in settings.'; exit }
|
|
||||||
$sas = $sas.Trim(); if (-not $sas.StartsWith('?')) { $sas = '?' + $sas }
|
|
||||||
try { Import-Module Az.Storage -ErrorAction Stop } catch {}
|
|
||||||
$storageContext = New-AzStorageContext -StorageAccountName $storageAccountName -SasToken $sas
|
|
||||||
|
|
||||||
# Ensure container exists
|
$domainController = $selectedDomain.DC
|
||||||
$container = Get-AzStorageContainer -Name $containerName -Context $storageContext -ErrorAction SilentlyContinue
|
|
||||||
if (-not $container) { Write-Error "Azure container '$containerName' not found or access denied."; exit }
|
|
||||||
|
|
||||||
# Upload the encrypted file to Azure Blob Storage
|
# Validate credentials and replication permissions before attempting DCSync
|
||||||
Set-AzStorageBlobContent -File $encryptedFilePath -Container $containerName -Blob $blobName -Context $storageContext | Out-Null
|
$hasADModule = $null -ne (Get-Module -Name ActiveDirectory -ErrorAction SilentlyContinue)
|
||||||
Write-Host "Encrypted file uploaded to Azure Blob Storage: $blobName"
|
if (-not $hasADModule) {
|
||||||
|
try { Import-Module ActiveDirectory -ErrorAction Stop; $hasADModule = $true } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
# Download the blob to a temporary location to verify
|
if ($hasADModule) {
|
||||||
$tempDownloadPath = [System.IO.Path]::GetTempFileName()
|
$credential = Get-ValidatedADCredential -DomainName $selectedDomain.Name -Server $domainController
|
||||||
Get-AzStorageBlobContent -Blob $blobName -Container $containerName -Context $storageContext -Destination $tempDownloadPath -Force | Out-Null
|
try {
|
||||||
|
$domainInfo = Get-ADDomain -Server $domainController -Credential $credential -ErrorAction Stop
|
||||||
# Calculate the downloaded file checksum
|
Test-ReplicationPermissions -DomainDN $domainInfo.DistinguishedName `
|
||||||
$downloadedFileChecksum = Get-FileChecksum -Path $tempDownloadPath
|
-Server $domainController -Credential $credential
|
||||||
}
|
} catch {
|
||||||
|
throw $_.Exception.Message
|
||||||
# Compare the checksums
|
}
|
||||||
if ($localFileChecksum -eq $downloadedFileChecksum) {
|
|
||||||
Write-Host "The file was correctly uploaded. Checksum verified."
|
|
||||||
# Clean up local and temporary files only on success
|
|
||||||
Remove-Item -Path $exportPath, $compressedFilePath, $encryptedFilePath, $tempDownloadPath -Force
|
|
||||||
if ($storageProvider -ieq 'S3') {
|
|
||||||
Write-Host "Local and temporary files cleaned up after uploading to S3-compatible storage."
|
|
||||||
} else {
|
} else {
|
||||||
Write-Host "Local and temporary files cleaned up after uploading to Azure Blob Storage."
|
Write-Warning "ActiveDirectory module not available; skipping credential pre-check and replication permission verification."
|
||||||
|
$credential = Get-Credential -Message "Enter AD credentials with replication rights for $($selectedDomain.Name)"
|
||||||
|
if ($null -eq $credential) { throw "Credential prompt was cancelled." }
|
||||||
|
}
|
||||||
|
|
||||||
|
$domainPrefix = ($selectedDomain.Name -replace "\W", "_")
|
||||||
|
$baseName = "${domainPrefix}_NTLM_Hashes_$timestamp"
|
||||||
|
$blobName = "$baseName.enc"
|
||||||
|
|
||||||
|
# Use a temp directory for all sensitive intermediate files so they are
|
||||||
|
# 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
|
||||||
|
$exportPath = Join-Path -Path $tmpDir.FullName -ChildPath "$baseName.txt"
|
||||||
|
$compressedFilePath = Join-Path -Path $tmpDir.FullName -ChildPath "$baseName.zip"
|
||||||
|
$encryptedFilePath = Join-Path -Path $tmpDir.FullName -ChildPath "$baseName.enc"
|
||||||
|
$tempDownloadPath = $null
|
||||||
|
|
||||||
|
try {
|
||||||
|
$ntlmHashes = Get-ADReplAccount -All -Server $domainController -Credential $credential |
|
||||||
|
Where-Object { $_.NTHash } |
|
||||||
|
ForEach-Object { [BitConverter]::ToString($_.NTHash).Replace("-", "") } |
|
||||||
|
Sort-Object -Unique
|
||||||
|
|
||||||
|
$ntlmHashes | Out-File -FilePath $exportPath
|
||||||
|
Write-Host "NTLM hashes have been extracted to temporary file."
|
||||||
|
|
||||||
|
Compress-Archive -Path $exportPath -DestinationPath $compressedFilePath
|
||||||
|
Write-Host "File has been compressed."
|
||||||
|
|
||||||
|
Protect-FileWithAES -InputFile $compressedFilePath -OutputFile $encryptedFilePath -Passphrase $passphrase
|
||||||
|
|
||||||
|
$localFileChecksum = Get-FileChecksum -Path $encryptedFilePath
|
||||||
|
|
||||||
|
if ($storageProvider -ieq 'S3') {
|
||||||
|
if ([string]::IsNullOrWhiteSpace($s3BucketName)) { throw 's3BucketName is missing in settings.' }
|
||||||
|
if ([string]::IsNullOrWhiteSpace($s3AccessKeyId) -or [string]::IsNullOrWhiteSpace($s3SecretAccessKey)) { throw 's3AccessKeyId / s3SecretAccessKey missing in settings.' }
|
||||||
|
if ([string]::IsNullOrWhiteSpace($s3EndpointUrl)) { throw 's3EndpointUrl is required for S3-compatible storage.' }
|
||||||
|
|
||||||
|
$usedAwsTools = $false
|
||||||
|
if ($s3UseAwsTools) {
|
||||||
|
try {
|
||||||
|
$s3Client = New-S3Client -EndpointUrl $s3EndpointUrl -Region $s3Region -AccessKeyId $s3AccessKeyId -SecretAccessKey $s3SecretAccessKey -ForcePathStyle:$s3ForcePathStyle
|
||||||
|
$putReq = New-Object Amazon.S3.Model.PutObjectRequest -Property @{ BucketName = $s3BucketName; Key = $blobName; FilePath = $encryptedFilePath }
|
||||||
|
$null = $s3Client.PutObject($putReq)
|
||||||
|
Write-Host "Encrypted file uploaded to S3-compatible bucket (AWS Tools): $blobName"
|
||||||
|
$tempDownloadPath = [System.IO.Path]::GetTempFileName()
|
||||||
|
$getReq = New-Object Amazon.S3.Model.GetObjectRequest -Property @{ BucketName = $s3BucketName; Key = $blobName }
|
||||||
|
$getResp = $s3Client.GetObject($getReq)
|
||||||
|
$getResp.WriteResponseStreamToFile($tempDownloadPath, $true)
|
||||||
|
$getResp.Dispose()
|
||||||
|
$usedAwsTools = $true
|
||||||
|
} catch {
|
||||||
|
Write-Warning "AWS Tools path failed or not available. Falling back to native HTTP (SigV4). Details: $($_.Exception.Message)"
|
||||||
|
$usedAwsTools = $false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $usedAwsTools) {
|
||||||
|
Invoke-S3PutFile -endpointUrl $s3EndpointUrl -bucket $s3BucketName -key $blobName -filePath $encryptedFilePath -region $s3Region -ak $s3AccessKeyId -sk $s3SecretAccessKey -forcePathStyle:$s3ForcePathStyle
|
||||||
|
Write-Host "Encrypted file uploaded to S3-compatible bucket: $blobName"
|
||||||
|
$tempDownloadPath = [System.IO.Path]::GetTempFileName()
|
||||||
|
Invoke-S3GetToFile -endpointUrl $s3EndpointUrl -bucket $s3BucketName -key $blobName -targetPath $tempDownloadPath -region $s3Region -ak $s3AccessKeyId -sk $s3SecretAccessKey -forcePathStyle:$s3ForcePathStyle
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$sas = $sasToken
|
||||||
|
if ([string]::IsNullOrWhiteSpace($sas)) { throw 'sasToken is missing in settings.' }
|
||||||
|
$sas = $sas.Trim(); if (-not $sas.StartsWith('?')) { $sas = '?' + $sas }
|
||||||
|
try { Import-Module Az.Storage -ErrorAction Stop } catch {}
|
||||||
|
$storageContext = New-AzStorageContext -StorageAccountName $storageAccountName -SasToken $sas
|
||||||
|
|
||||||
|
$container = Get-AzStorageContainer -Name $containerName -Context $storageContext -ErrorAction SilentlyContinue
|
||||||
|
if (-not $container) { throw "Azure container '$containerName' not found or access denied." }
|
||||||
|
|
||||||
|
Set-AzStorageBlobContent -File $encryptedFilePath -Container $containerName -Blob $blobName -Context $storageContext | Out-Null
|
||||||
|
Write-Host "Encrypted file uploaded to Azure Blob Storage: $blobName"
|
||||||
|
|
||||||
|
$tempDownloadPath = [System.IO.Path]::GetTempFileName()
|
||||||
|
Get-AzStorageBlobContent -Blob $blobName -Container $containerName -Context $storageContext -Destination $tempDownloadPath -Force | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
$downloadedFileChecksum = Get-FileChecksum -Path $tempDownloadPath
|
||||||
|
|
||||||
|
if ($localFileChecksum -eq $downloadedFileChecksum) {
|
||||||
|
Write-Host "The file was correctly uploaded. Checksum verified."
|
||||||
|
Remove-Item -Path $encryptedFilePath -Force
|
||||||
|
Remove-Item -Path $tempDownloadPath -Force
|
||||||
|
if ($storageProvider -ieq 'S3') {
|
||||||
|
Write-Host "Upload to S3-compatible storage completed and verified."
|
||||||
|
} else {
|
||||||
|
Write-Host "Upload to Azure Blob Storage completed and verified."
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Write-Warning "Checksum verification failed. Encrypted file preserved for investigation: $encryptedFilePath"
|
||||||
|
if ($tempDownloadPath -and (Test-Path $tempDownloadPath)) {
|
||||||
|
Remove-Item -Path $tempDownloadPath -Force -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
# Always delete plaintext hashes and compressed archive regardless of outcome.
|
||||||
|
foreach ($f in @($exportPath, $compressedFilePath)) {
|
||||||
|
if ($f -and (Test-Path $f)) {
|
||||||
|
Remove-Item -Path $f -Force -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
else {
|
|
||||||
Write-Warning "Checksum verification failed. Keeping local artifacts for investigation: $exportPath, $compressedFilePath, $encryptedFilePath"
|
|
||||||
if (Test-Path $tempDownloadPath) { Remove-Item -Path $tempDownloadPath -Force }
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Host "Script execution completed."
|
Write-Host "Script execution completed."
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
+1
-155
@@ -7,7 +7,7 @@
|
|||||||
##################################################
|
##################################################
|
||||||
## Project: Elysium ##
|
## Project: Elysium ##
|
||||||
## File: Prepare-KHDBStorage.ps1 ##
|
## File: Prepare-KHDBStorage.ps1 ##
|
||||||
## Version: 2.2.0 ##
|
## Version: 2.4.2 ##
|
||||||
## Support: support@cqre.net ##
|
## Support: support@cqre.net ##
|
||||||
##################################################
|
##################################################
|
||||||
|
|
||||||
@@ -100,44 +100,6 @@ function Remove-DirectoryContents {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function Read-KeyValueSettingsFile {
|
|
||||||
param([string]$Path)
|
|
||||||
$result = @{}
|
|
||||||
if (-not (Test-Path -LiteralPath $Path)) { return $result }
|
|
||||||
foreach ($line in (Get-Content -LiteralPath $Path)) {
|
|
||||||
if ($null -eq $line) { continue }
|
|
||||||
$trimmed = $line.Trim()
|
|
||||||
if (-not $trimmed) { continue }
|
|
||||||
if ($trimmed.StartsWith('#')) { continue }
|
|
||||||
$kv = $line -split '=', 2
|
|
||||||
if ($kv.Count -ne 2) { continue }
|
|
||||||
$key = $kv[0].Trim()
|
|
||||||
$value = $kv[1].Trim()
|
|
||||||
if (-not $key) { continue }
|
|
||||||
if ($value.StartsWith("'") -and $value.EndsWith("'") -and $value.Length -ge 2) {
|
|
||||||
$value = $value.Substring(1, $value.Length - 2)
|
|
||||||
}
|
|
||||||
$result[$key] = $value
|
|
||||||
}
|
|
||||||
return $result
|
|
||||||
}
|
|
||||||
|
|
||||||
function Get-SettingsValue {
|
|
||||||
param(
|
|
||||||
[hashtable]$Settings,
|
|
||||||
[string]$Key
|
|
||||||
)
|
|
||||||
if (-not $Settings) { return $null }
|
|
||||||
if ($Settings.ContainsKey($Key)) { return $Settings[$Key] }
|
|
||||||
return $null
|
|
||||||
}
|
|
||||||
|
|
||||||
function Get-FunctionDefinitionText {
|
|
||||||
param([Parameter(Mandatory = $true)][string]$Name)
|
|
||||||
$cmd = Get-Command -Name $Name -CommandType Function -ErrorAction Stop
|
|
||||||
return $cmd.ScriptBlock.Ast.Extent.Text
|
|
||||||
}
|
|
||||||
|
|
||||||
function Merge-ShardsToFile {
|
function Merge-ShardsToFile {
|
||||||
param(
|
param(
|
||||||
[psobject]$Manifest,
|
[psobject]$Manifest,
|
||||||
@@ -176,27 +138,6 @@ function Get-NormalizedForwardPath {
|
|||||||
return $PathValue.Replace('\', '/').Trim('/')
|
return $PathValue.Replace('\', '/').Trim('/')
|
||||||
}
|
}
|
||||||
|
|
||||||
function Build-BlobUri {
|
|
||||||
param(
|
|
||||||
[string]$Account,
|
|
||||||
[string]$Container,
|
|
||||||
[string]$Sas,
|
|
||||||
[string]$BlobName
|
|
||||||
)
|
|
||||||
|
|
||||||
if ([string]::IsNullOrWhiteSpace($Account)) { throw 'storageAccountName is missing or empty.' }
|
|
||||||
if ([string]::IsNullOrWhiteSpace($Container)) { throw 'containerName is missing or empty.' }
|
|
||||||
if ([string]::IsNullOrWhiteSpace($Sas)) { throw 'sasToken is missing or empty.' }
|
|
||||||
if ([string]::IsNullOrWhiteSpace($BlobName)) { throw 'BlobName cannot be empty.' }
|
|
||||||
|
|
||||||
$sas = $Sas.Trim()
|
|
||||||
if (-not $sas.StartsWith('?')) { $sas = '?' + $sas }
|
|
||||||
$normalizedBlob = $BlobName.Replace('\', '/').TrimStart('/')
|
|
||||||
$builder = [System.UriBuilder]::new("https://$Account.blob.core.windows.net/$Container/$normalizedBlob")
|
|
||||||
$builder.Query = $sas.TrimStart('?')
|
|
||||||
return $builder.Uri.AbsoluteUri
|
|
||||||
}
|
|
||||||
|
|
||||||
function Upload-AzureBlob {
|
function Upload-AzureBlob {
|
||||||
param(
|
param(
|
||||||
[string]$Account,
|
[string]$Account,
|
||||||
@@ -232,88 +173,6 @@ function Upload-AzureBlob {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function Get-Bytes([string]$s) { return [System.Text.Encoding]::UTF8.GetBytes($s) }
|
|
||||||
function Get-HashHex([byte[]]$bytes) {
|
|
||||||
if ($null -eq $bytes) { $bytes = [byte[]]@() }
|
|
||||||
$sha = [System.Security.Cryptography.SHA256]::Create()
|
|
||||||
try {
|
|
||||||
$ms = New-Object System.IO.MemoryStream -ArgumentList (,$bytes)
|
|
||||||
try {
|
|
||||||
$hash = $sha.ComputeHash([System.IO.Stream]$ms)
|
|
||||||
} finally { $ms.Dispose() }
|
|
||||||
return ([BitConverter]::ToString($hash)).Replace('-', '').ToLowerInvariant()
|
|
||||||
} finally { $sha.Dispose() }
|
|
||||||
}
|
|
||||||
function HmacSha256([byte[]]$key, [string]$data) {
|
|
||||||
$h = [System.Security.Cryptography.HMACSHA256]::new($key)
|
|
||||||
try {
|
|
||||||
$b = [System.Text.Encoding]::UTF8.GetBytes($data)
|
|
||||||
$ms = New-Object System.IO.MemoryStream -ArgumentList (,$b)
|
|
||||||
try {
|
|
||||||
return $h.ComputeHash([System.IO.Stream]$ms)
|
|
||||||
} finally { $ms.Dispose() }
|
|
||||||
} finally { $h.Dispose() }
|
|
||||||
}
|
|
||||||
function GetSignatureKey([string]$secret, [string]$dateStamp, [string]$regionName, [string]$serviceName) {
|
|
||||||
$kDate = HmacSha256 (Get-Bytes ('AWS4' + $secret)) $dateStamp
|
|
||||||
$kRegion = HmacSha256 $kDate $regionName
|
|
||||||
$kService = HmacSha256 $kRegion $serviceName
|
|
||||||
HmacSha256 $kService 'aws4_request'
|
|
||||||
}
|
|
||||||
function UriEncode([string]$data, [bool]$encodeSlash) {
|
|
||||||
$enc = [System.Uri]::EscapeDataString($data)
|
|
||||||
if (-not $encodeSlash) { $enc = $enc -replace '%2F', '/' }
|
|
||||||
return $enc
|
|
||||||
}
|
|
||||||
function BuildCanonicalPath([System.Uri]$uri) {
|
|
||||||
$segments = $uri.AbsolutePath.Split('/')
|
|
||||||
$encoded = @()
|
|
||||||
foreach ($s in $segments) { $encoded += (UriEncode $s $false) }
|
|
||||||
$path = ($encoded -join '/')
|
|
||||||
if (-not $path.StartsWith('/')) { $path = '/' + $path }
|
|
||||||
return $path
|
|
||||||
}
|
|
||||||
function ToHex([byte[]]$b) { ([BitConverter]::ToString($b)).Replace('-', '').ToLowerInvariant() }
|
|
||||||
function BuildAuthHeaders($method, [System.Uri]$uri, [string]$region, [string]$accessKey, [string]$secretKey, [string]$payloadHash) {
|
|
||||||
$algorithm = 'AWS4-HMAC-SHA256'
|
|
||||||
$timestamp = (Get-Date).ToUniversalTime()
|
|
||||||
$amzDate = $timestamp.ToString('yyyyMMddTHHmmssZ')
|
|
||||||
$dateStamp = $timestamp.ToString('yyyyMMdd')
|
|
||||||
$hostHeader = $uri.Host
|
|
||||||
if (-not $uri.IsDefaultPort) { $hostHeader = "${hostHeader}:$($uri.Port)" }
|
|
||||||
$canonicalUri = BuildCanonicalPath $uri
|
|
||||||
$canonicalQueryString = ''
|
|
||||||
$canonicalHeaders = "host:$hostHeader`n" + "x-amz-content-sha256:$payloadHash`n" + "x-amz-date:$amzDate`n"
|
|
||||||
$signedHeaders = 'host;x-amz-content-sha256;x-amz-date'
|
|
||||||
$canonicalRequest = "$method`n$canonicalUri`n$canonicalQueryString`n$canonicalHeaders`n$signedHeaders`n$payloadHash"
|
|
||||||
$credentialScope = "$dateStamp/$region/s3/aws4_request"
|
|
||||||
$stringToSign = "$algorithm`n$amzDate`n$credentialScope`n$((Get-HashHex (Get-Bytes $canonicalRequest)))"
|
|
||||||
$signingKey = GetSignatureKey $secretKey $dateStamp $region 's3'
|
|
||||||
$signature = ToHex (HmacSha256 $signingKey $stringToSign)
|
|
||||||
$authHeader = "$algorithm Credential=$accessKey/$credentialScope, SignedHeaders=$signedHeaders, Signature=$signature"
|
|
||||||
@{
|
|
||||||
'x-amz-date' = $amzDate
|
|
||||||
'x-amz-content-sha256' = $payloadHash
|
|
||||||
'Authorization' = $authHeader
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function BuildS3Uri([string]$endpointUrl, [string]$bucket, [string]$key, [bool]$forcePathStyle) {
|
|
||||||
$base = [System.Uri]$endpointUrl
|
|
||||||
$builder = [System.UriBuilder]::new($base)
|
|
||||||
$normalizedKey = $key.Replace('\', '/').TrimStart('/')
|
|
||||||
if ($forcePathStyle) {
|
|
||||||
$path = $builder.Path.TrimEnd('/')
|
|
||||||
if ([string]::IsNullOrEmpty($path)) { $path = '/' }
|
|
||||||
$builder.Path = ($path.TrimEnd('/') + '/' + $bucket + '/' + $normalizedKey)
|
|
||||||
} else {
|
|
||||||
$builder.Host = "$bucket." + $builder.Host
|
|
||||||
$path = $builder.Path.TrimEnd('/')
|
|
||||||
if ([string]::IsNullOrEmpty($path)) { $path = '/' }
|
|
||||||
$builder.Path = ($path.TrimEnd('/') + '/' + $normalizedKey)
|
|
||||||
}
|
|
||||||
return $builder.Uri
|
|
||||||
}
|
|
||||||
|
|
||||||
function Invoke-S3HttpUpload {
|
function Invoke-S3HttpUpload {
|
||||||
param(
|
param(
|
||||||
[string]$EndpointUrl,
|
[string]$EndpointUrl,
|
||||||
@@ -356,19 +215,6 @@ function Invoke-S3HttpUpload {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function Combine-StoragePath {
|
|
||||||
param(
|
|
||||||
[string]$Prefix,
|
|
||||||
[string]$Name
|
|
||||||
)
|
|
||||||
|
|
||||||
$cleanName = $Name.Replace('\', '/').TrimStart('/')
|
|
||||||
if ([string]::IsNullOrWhiteSpace($Prefix)) { return $cleanName }
|
|
||||||
$normalizedPrefix = $Prefix.Replace('\', '/').Trim('/')
|
|
||||||
if ([string]::IsNullOrEmpty($normalizedPrefix)) { return $cleanName }
|
|
||||||
return "$normalizedPrefix/$cleanName"
|
|
||||||
}
|
|
||||||
|
|
||||||
function Split-KhdbIntoShards {
|
function Split-KhdbIntoShards {
|
||||||
param(
|
param(
|
||||||
[string]$Source,
|
[string]$Source,
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
# Settings for Elysium Tool
|
|
||||||
|
|
||||||
# General Settings
|
|
||||||
$Global:ToolRepositoryUrl = "https://example.com/git/elysium.git"
|
|
||||||
|
|
||||||
# KHDB Update Settings
|
|
||||||
$Global:KnownHashesBaseUrl = "https://example.com/known-hashes/"
|
|
||||||
$Global:LocalKnownHashesPath = "C:\Elysium\known-hashes"
|
|
||||||
|
|
||||||
# Test Weak AD Passwords Settings
|
|
||||||
$Global:DomainAdminUsernames = @{
|
|
||||||
"Domain1" = "admin1";
|
|
||||||
"Domain2" = "admin2";
|
|
||||||
# Add more domains and usernames as needed
|
|
||||||
}
|
|
||||||
$Global:PdfReportPath = "C:\Elysium\Reports"
|
|
||||||
|
|
||||||
# Extract and Send Hashes Settings
|
|
||||||
$Global:HashesExportPath = "C:\Elysium\Hashes"
|
|
||||||
$Global:ToolProviderUploadUrl = "https://upload.example.com/hashes"
|
|
||||||
|
|
||||||
# Any additional settings...
|
|
||||||
+40
-128
@@ -8,7 +8,7 @@
|
|||||||
##################################################
|
##################################################
|
||||||
## Project: Elysium ##
|
## Project: Elysium ##
|
||||||
## File: Test-WeakADPasswords.ps1 ##
|
## File: Test-WeakADPasswords.ps1 ##
|
||||||
## Version: 2.2.0 ##
|
## Version: 2.4.2 ##
|
||||||
## Support: support@cqre.net ##
|
## Support: support@cqre.net ##
|
||||||
##################################################
|
##################################################
|
||||||
|
|
||||||
@@ -21,10 +21,13 @@ Weak AD password finder component of Elysium tool.
|
|||||||
This script will test the passwords of selected domain (defined in ElysiumSettings.txt) using DSInternals' Test-PasswordQuality cmdlet. It writes its output to a report file which is meant to be shared with the internal security team. The report now includes UPNs for each account mentioned.
|
This script will test the passwords of selected domain (defined in ElysiumSettings.txt) using DSInternals' Test-PasswordQuality cmdlet. It writes its output to a report file which is meant to be shared with the internal security team. The report now includes UPNs for each account mentioned.
|
||||||
#>
|
#>
|
||||||
|
|
||||||
# Enable verbose output
|
|
||||||
$ErrorActionPreference = 'Stop'
|
$ErrorActionPreference = 'Stop'
|
||||||
Set-StrictMode -Version Latest
|
Set-StrictMode -Version Latest
|
||||||
|
|
||||||
|
[string]$commonHelper = Join-Path -Path $PSScriptRoot -ChildPath 'Elysium.Common.ps1'
|
||||||
|
if (-not (Test-Path -LiteralPath $commonHelper)) { throw "Common helper not found at $commonHelper" }
|
||||||
|
. $commonHelper
|
||||||
|
|
||||||
$VerbosePreference = "SilentlyContinue"
|
$VerbosePreference = "SilentlyContinue"
|
||||||
|
|
||||||
$scriptRoot = $PSScriptRoot
|
$scriptRoot = $PSScriptRoot
|
||||||
@@ -92,7 +95,7 @@ function Invoke-UsageBeacon {
|
|||||||
if ($normalizedMethod -in @('POST', 'PUT')) {
|
if ($normalizedMethod -in @('POST', 'PUT')) {
|
||||||
$payload = [ordered]@{
|
$payload = [ordered]@{
|
||||||
script = 'Test-WeakADPasswords'
|
script = 'Test-WeakADPasswords'
|
||||||
version = '1.4.5'
|
version = $ElysiumVersion
|
||||||
ranAtUtc = (Get-Date).ToUniversalTime().ToString('o')
|
ranAtUtc = (Get-Date).ToUniversalTime().ToString('o')
|
||||||
}
|
}
|
||||||
if (-not [string]::IsNullOrWhiteSpace($InstanceId)) {
|
if (-not [string]::IsNullOrWhiteSpace($InstanceId)) {
|
||||||
@@ -124,32 +127,9 @@ $footer = "`r`n==== End of Report ===="
|
|||||||
|
|
||||||
Start-TestTranscript -BasePath $scriptRoot
|
Start-TestTranscript -BasePath $scriptRoot
|
||||||
try {
|
try {
|
||||||
# Import settings
|
|
||||||
Write-Verbose "Loading settings..."
|
Write-Verbose "Loading settings..."
|
||||||
$ElysiumSettings = @{}
|
$ElysiumSettings = Read-ElysiumSettings -ScriptRoot $scriptRoot
|
||||||
$settingsPath = Join-Path -Path $scriptRoot -ChildPath "ElysiumSettings.txt"
|
Write-Verbose "Settings loaded successfully."
|
||||||
|
|
||||||
# Ensure the settings file exists
|
|
||||||
if (-not (Test-Path $settingsPath)) {
|
|
||||||
Write-Error "Settings file not found at $settingsPath"
|
|
||||||
exit
|
|
||||||
}
|
|
||||||
|
|
||||||
# Load settings from file
|
|
||||||
try {
|
|
||||||
Get-Content $settingsPath | ForEach-Object {
|
|
||||||
if (-not [string]::IsNullOrWhiteSpace($_) -and -not $_.StartsWith("#")) {
|
|
||||||
$keyValue = $_ -split '=', 2
|
|
||||||
if ($keyValue.Count -eq 2) {
|
|
||||||
$ElysiumSettings[$keyValue[0].Trim()] = $keyValue[1].Trim()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Write-Verbose "Settings loaded successfully."
|
|
||||||
} catch {
|
|
||||||
Write-Error ("An error occurred while loading settings: {0}" -f $_.Exception.Message)
|
|
||||||
exit
|
|
||||||
}
|
|
||||||
|
|
||||||
$usageBeaconUrl = $ElysiumSettings['UsageBeaconUrl']
|
$usageBeaconUrl = $ElysiumSettings['UsageBeaconUrl']
|
||||||
$usageBeaconMethod = $ElysiumSettings['UsageBeaconMethod']
|
$usageBeaconMethod = $ElysiumSettings['UsageBeaconMethod']
|
||||||
@@ -372,7 +352,14 @@ function Import-CompatModule {
|
|||||||
|
|
||||||
$nonFipsErrors = @($importErrors | Where-Object { $_.Exception.Message -notmatch 'Only FIPS certified cryptographic algorithms are enabled in \.NET' })
|
$nonFipsErrors = @($importErrors | Where-Object { $_.Exception.Message -notmatch 'Only FIPS certified cryptographic algorithms are enabled in \.NET' })
|
||||||
if ($nonFipsErrors.Count -gt 0) {
|
if ($nonFipsErrors.Count -gt 0) {
|
||||||
Write-Warning ("DSInternals import reported non-fatal warning(s): {0}" -f $nonFipsErrors[0].Exception.Message)
|
$nonFipsMsg = $nonFipsErrors[0].Exception.Message
|
||||||
|
if ($nonFipsMsg -match 'Zone\.Identifier|alternate data stream') {
|
||||||
|
$dsModule = Get-Module -Name DSInternals -ErrorAction SilentlyContinue
|
||||||
|
if (-not $dsModule) { $dsModule = Get-Module -ListAvailable -Name DSInternals -ErrorAction SilentlyContinue | Select-Object -First 1 }
|
||||||
|
$dsPath = if ($dsModule) { $dsModule.ModuleBase } else { '<DSInternals module path>' }
|
||||||
|
throw ("DSInternals native DLL is blocked by Windows (Zone.Identifier). Run the following on the target machine and retry:`n Get-ChildItem -Path '$dsPath' -Recurse | Unblock-File")
|
||||||
|
}
|
||||||
|
Write-Warning ("DSInternals import reported non-fatal warning(s): {0}" -f $nonFipsMsg)
|
||||||
}
|
}
|
||||||
|
|
||||||
Write-Verbose ("Imported module '{0}' (Core={1}, Windows={2})" -f $Name, $runningInPSCore, $onWindows)
|
Write-Verbose ("Imported module '{0}' (Core={1}, Windows={2})" -f $Name, $runningInPSCore, $onWindows)
|
||||||
@@ -405,6 +392,28 @@ try {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Version check: v6.2 was unsigned (blocks native DLLs, causes replication failures);
|
||||||
|
# v7.0 fixes intermittent CRC errors mid-replication and Test-PasswordQuality result truncation.
|
||||||
|
$dsInternalsVersion = (Get-Module -Name DSInternals).Version
|
||||||
|
$minimumVersion = [version]'7.0'
|
||||||
|
$unsignedVersion = [version]'6.2'
|
||||||
|
if ($dsInternalsVersion -eq $unsignedVersion) {
|
||||||
|
Write-Warning ("DSInternals {0} is not digitally signed, which blocks its native DLLs and causes replication failures. Update to v7.0+: Install-Module DSInternals -Force -AllowClobber" -f $dsInternalsVersion)
|
||||||
|
} elseif ($dsInternalsVersion -lt $minimumVersion) {
|
||||||
|
$resp = Read-Host ("DSInternals {0} is installed; v7.0 fixes intermittent replication CRC errors and result truncation. Update now? [Y/N]" -f $dsInternalsVersion)
|
||||||
|
if ($resp -match '^(?i:y|yes)$') {
|
||||||
|
try {
|
||||||
|
# Install-Module -Force is used instead of Update-Module to avoid a PowerShellGet bug
|
||||||
|
# where null PublishedDate metadata causes "cannot convert null to type system.datetime"
|
||||||
|
Install-Module -Name DSInternals -Force -AllowClobber -ErrorAction Stop
|
||||||
|
Write-Host '[+] DSInternals updated. Please re-run the script to load the new version.'
|
||||||
|
exit 0
|
||||||
|
} catch {
|
||||||
|
Write-Warning ("DSInternals update failed: {0}" -f $_.Exception.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
# Resolve KHDB path with fallbacks
|
# Resolve KHDB path with fallbacks
|
||||||
$installationPath = $ElysiumSettings["InstallationPath"]
|
$installationPath = $ElysiumSettings["InstallationPath"]
|
||||||
if ([string]::IsNullOrWhiteSpace($installationPath)) { $installationPath = $scriptRoot }
|
if ([string]::IsNullOrWhiteSpace($installationPath)) { $installationPath = $scriptRoot }
|
||||||
@@ -561,104 +570,6 @@ function Resolve-DSInternalsWeakHashFile {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function Get-ValidatedADCredential {
|
|
||||||
param (
|
|
||||||
[Parameter(Mandatory)][string]$DomainName,
|
|
||||||
[Parameter(Mandatory)][string]$Server,
|
|
||||||
[int]$MaxAttempts = 3
|
|
||||||
)
|
|
||||||
|
|
||||||
for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) {
|
|
||||||
$credential = Get-Credential -Message "Enter AD credentials with replication rights for $DomainName (attempt $attempt/$MaxAttempts)"
|
|
||||||
if ($null -eq $credential) {
|
|
||||||
throw "Credential prompt was cancelled."
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
Get-ADDomain -Server $Server -Credential $credential -ErrorAction Stop | Out-Null
|
|
||||||
Write-Verbose ("Credential pre-check succeeded for '{0}' against '{1}'." -f $credential.UserName, $Server)
|
|
||||||
return $credential
|
|
||||||
} catch {
|
|
||||||
$message = $_.Exception.Message
|
|
||||||
if ($message -match 'rejected the client credentials|unknown user name|bad password|logon failure') {
|
|
||||||
Write-Warning ("Credentials were rejected for '{0}' (attempt {1}/{2})." -f $credential.UserName, $attempt, $MaxAttempts)
|
|
||||||
if ($attempt -lt $MaxAttempts) { continue }
|
|
||||||
throw "Credentials were rejected by domain controller '$Server' after $MaxAttempts attempts."
|
|
||||||
}
|
|
||||||
throw "Credential pre-check failed against '$Server': $message"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function Test-ReplicationPermissions {
|
|
||||||
param(
|
|
||||||
[Parameter(Mandatory)][string]$DomainDN,
|
|
||||||
[Parameter(Mandatory)][string]$Server,
|
|
||||||
[Parameter(Mandatory)][System.Management.Automation.PSCredential]$Credential
|
|
||||||
)
|
|
||||||
|
|
||||||
$requiredRights = [ordered]@{
|
|
||||||
'Replicating Directory Changes' = [guid]'1131f6aa-9c07-11d1-f79f-00c04fc2dcd2'
|
|
||||||
'Replicating Directory Changes All' = [guid]'1131f6ab-9c07-11d1-f79f-00c04fc2dcd2'
|
|
||||||
'Replicating Directory Changes In Filtered Set' = [guid]'89e95b76-444d-4c62-991a-0facbeda640c'
|
|
||||||
}
|
|
||||||
|
|
||||||
# Collect caller SID + direct group SIDs so we can match ACEs below
|
|
||||||
$callerSids = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
|
|
||||||
try {
|
|
||||||
$samName = $Credential.UserName -replace '^.*\\', ''
|
|
||||||
$adUser = Get-ADUser -Identity $samName -Server $Server -Credential $Credential `
|
|
||||||
-Properties SID, MemberOf -ErrorAction Stop
|
|
||||||
[void]$callerSids.Add($adUser.SID.Value)
|
|
||||||
foreach ($groupDN in @($adUser.MemberOf)) {
|
|
||||||
try {
|
|
||||||
$g = Get-ADGroup -Identity $groupDN -Server $Server -Credential $Credential `
|
|
||||||
-Properties SID -ErrorAction Stop
|
|
||||||
[void]$callerSids.Add($g.SID.Value)
|
|
||||||
} catch { }
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
Write-Warning ("Could not resolve account SIDs for replication permission pre-check: {0}. Skipping." -f $_.Exception.Message)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
# Read the domain object's DACL via ADSI so we can use the provided credential
|
|
||||||
$acl = $null
|
|
||||||
try {
|
|
||||||
$de = New-Object System.DirectoryServices.DirectoryEntry(
|
|
||||||
"LDAP://$Server/$DomainDN",
|
|
||||||
$Credential.UserName,
|
|
||||||
$Credential.GetNetworkCredential().Password
|
|
||||||
)
|
|
||||||
# Translate all trustees to SID form for consistent comparison
|
|
||||||
$acl = $de.ObjectSecurity.GetAccessRules(
|
|
||||||
$true, $true, [System.Security.Principal.SecurityIdentifier])
|
|
||||||
} catch {
|
|
||||||
Write-Warning ("Could not read domain object ACL for replication permission pre-check: {0}. Skipping." -f $_.Exception.Message)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
$missing = @()
|
|
||||||
foreach ($rightName in $requiredRights.Keys) {
|
|
||||||
$guid = $requiredRights[$rightName]
|
|
||||||
$granted = $false
|
|
||||||
foreach ($ace in $acl) {
|
|
||||||
if ($ace.AccessControlType -ne [System.Security.AccessControl.AccessControlType]::Allow) { continue }
|
|
||||||
if (-not ($ace.ActiveDirectoryRights -band [System.DirectoryServices.ActiveDirectoryRights]::ExtendedRight)) { continue }
|
|
||||||
if ($ace.ObjectType -ne $guid) { continue }
|
|
||||||
if ($callerSids.Contains($ace.IdentityReference.Value)) { $granted = $true; break }
|
|
||||||
}
|
|
||||||
if (-not $granted) { $missing += $rightName }
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($missing.Count -gt 0) {
|
|
||||||
throw ("Account '{0}' is missing the following replication permissions on '{1}':`n - {2}`n`nGrant these extended rights on the domain object to allow DCSync-based hash retrieval." -f `
|
|
||||||
$Credential.UserName, $DomainDN, ($missing -join "`n - "))
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Verbose ("Replication permission pre-check passed for '{0}'." -f $Credential.UserName)
|
|
||||||
}
|
|
||||||
|
|
||||||
# Function to test for weak AD passwords
|
# Function to test for weak AD passwords
|
||||||
function Test-WeakADPasswords {
|
function Test-WeakADPasswords {
|
||||||
param (
|
param (
|
||||||
@@ -693,9 +604,10 @@ function Test-WeakADPasswords {
|
|||||||
Write-Verbose ("Using credential supplied by caller: {0}" -f $credential.UserName)
|
Write-Verbose ("Using credential supplied by caller: {0}" -f $credential.UserName)
|
||||||
}
|
}
|
||||||
|
|
||||||
# Verify the account has the three replication extended rights before attempting DCSync
|
# Pre-flight checks before attempting DCSync
|
||||||
try {
|
try {
|
||||||
$domainInfo = Get-ADDomain -Server $selectedDomain["DC"] -Credential $credential -ErrorAction Stop
|
$domainInfo = Get-ADDomain -Server $selectedDomain["DC"] -Credential $credential -ErrorAction Stop
|
||||||
|
Test-DCClockSkew -Server $selectedDomain["DC"] -Credential $credential
|
||||||
Test-ReplicationPermissions -DomainDN $domainInfo.DistinguishedName `
|
Test-ReplicationPermissions -DomainDN $domainInfo.DistinguishedName `
|
||||||
-Server $selectedDomain["DC"] -Credential $credential
|
-Server $selectedDomain["DC"] -Credential $credential
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
+1
-1
@@ -7,7 +7,7 @@
|
|||||||
##################################################
|
##################################################
|
||||||
## Project: Elysium ##
|
## Project: Elysium ##
|
||||||
## File: Uninstall.ps1 ##
|
## File: Uninstall.ps1 ##
|
||||||
## Version: 2.2.0 ##
|
## Version: 2.4.2 ##
|
||||||
## Support: support@cqre.net ##
|
## Support: support@cqre.net ##
|
||||||
##################################################
|
##################################################
|
||||||
|
|
||||||
|
|||||||
+98
-261
@@ -7,7 +7,7 @@
|
|||||||
##################################################
|
##################################################
|
||||||
## Project: Elysium ##
|
## Project: Elysium ##
|
||||||
## File: Update-KHDB.ps1 ##
|
## File: Update-KHDB.ps1 ##
|
||||||
## Version: 2.2.0 ##
|
## Version: 2.4.2 ##
|
||||||
## Support: support@cqre.net ##
|
## Support: support@cqre.net ##
|
||||||
##################################################
|
##################################################
|
||||||
|
|
||||||
@@ -50,21 +50,6 @@ function Stop-UpdateTranscript {
|
|||||||
try { Stop-Transcript | Out-Null } catch {}
|
try { Stop-Transcript | Out-Null } catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
function Read-ElysiumSettings {
|
|
||||||
$settings = @{}
|
|
||||||
$settingsPath = Join-Path -Path $scriptRoot -ChildPath 'ElysiumSettings.txt'
|
|
||||||
if (-not (Test-Path $settingsPath)) { throw "Settings file not found at $settingsPath" }
|
|
||||||
Get-Content $settingsPath | ForEach-Object {
|
|
||||||
if ($_ -and -not $_.Trim().StartsWith('#')) {
|
|
||||||
$kv = $_ -split '=', 2
|
|
||||||
if ($kv.Count -eq 2) {
|
|
||||||
$settings[$kv[0].Trim()] = $kv[1].Trim().Trim("'")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return $settings
|
|
||||||
}
|
|
||||||
|
|
||||||
function Get-InstallationPath([hashtable]$settings) {
|
function Get-InstallationPath([hashtable]$settings) {
|
||||||
$p = $settings['InstallationPath']
|
$p = $settings['InstallationPath']
|
||||||
if ([string]::IsNullOrWhiteSpace($p)) { return $scriptRoot }
|
if ([string]::IsNullOrWhiteSpace($p)) { return $scriptRoot }
|
||||||
@@ -76,218 +61,10 @@ function New-HttpClient {
|
|||||||
Add-Type -AssemblyName System.Net.Http -ErrorAction SilentlyContinue
|
Add-Type -AssemblyName System.Net.Http -ErrorAction SilentlyContinue
|
||||||
$client = [System.Net.Http.HttpClient]::new()
|
$client = [System.Net.Http.HttpClient]::new()
|
||||||
$client.Timeout = [TimeSpan]::FromSeconds(600)
|
$client.Timeout = [TimeSpan]::FromSeconds(600)
|
||||||
$client.DefaultRequestHeaders.UserAgent.ParseAdd('Elysium/2.1.1 (+Update-KHDB)')
|
$client.DefaultRequestHeaders.UserAgent.ParseAdd("Elysium/$ElysiumVersion (+Update-KHDB)")
|
||||||
return $client
|
return $client
|
||||||
}
|
}
|
||||||
|
|
||||||
function Build-BlobUri {
|
|
||||||
param(
|
|
||||||
[string]$Account,
|
|
||||||
[string]$Container,
|
|
||||||
[string]$Sas,
|
|
||||||
[string]$BlobName
|
|
||||||
)
|
|
||||||
|
|
||||||
if ([string]::IsNullOrWhiteSpace($Account)) { throw 'storageAccountName is missing or empty.' }
|
|
||||||
if ([string]::IsNullOrWhiteSpace($Container)) { throw 'containerName is missing or empty.' }
|
|
||||||
if ([string]::IsNullOrWhiteSpace($Sas)) { throw 'sasToken is missing or empty.' }
|
|
||||||
if ([string]::IsNullOrWhiteSpace($BlobName)) { throw 'BlobName cannot be empty.' }
|
|
||||||
|
|
||||||
$sas = $Sas.Trim()
|
|
||||||
if (-not $sas.StartsWith('?')) { $sas = '?' + $sas }
|
|
||||||
$normalizedBlob = $BlobName.Replace('\', '/').TrimStart('/')
|
|
||||||
$uriBuilder = [System.UriBuilder]::new("https://$Account.blob.core.windows.net/$Container/$normalizedBlob")
|
|
||||||
$uriBuilder.Query = $sas.TrimStart('?')
|
|
||||||
return $uriBuilder.Uri.AbsoluteUri
|
|
||||||
}
|
|
||||||
|
|
||||||
function Ensure-AWSS3Module {
|
|
||||||
try { $null = [Amazon.S3.AmazonS3Client]; return } catch {}
|
|
||||||
try { Import-Module -Name AWS.Tools.S3 -ErrorAction Stop; return } catch {}
|
|
||||||
try { Import-Module -Name AWSPowerShell.NetCore -ErrorAction Stop; return } catch {}
|
|
||||||
throw "AWS Tools for PowerShell not found. Install with: Install-Module AWS.Tools.S3 -Scope CurrentUser"
|
|
||||||
}
|
|
||||||
|
|
||||||
function Get-FunctionDefinitionText {
|
|
||||||
param([Parameter(Mandatory = $true)][string]$Name)
|
|
||||||
$cmd = Get-Command -Name $Name -CommandType Function -ErrorAction Stop
|
|
||||||
return $cmd.ScriptBlock.Ast.Extent.Text
|
|
||||||
}
|
|
||||||
|
|
||||||
function New-S3Client {
|
|
||||||
param(
|
|
||||||
[string]$EndpointUrl,
|
|
||||||
[string]$Region,
|
|
||||||
[string]$AccessKeyId,
|
|
||||||
[string]$SecretAccessKey,
|
|
||||||
[bool]$ForcePathStyle = $true
|
|
||||||
)
|
|
||||||
|
|
||||||
Ensure-AWSS3Module
|
|
||||||
$creds = New-Object Amazon.Runtime.BasicAWSCredentials($AccessKeyId, $SecretAccessKey)
|
|
||||||
$cfg = New-Object Amazon.S3.AmazonS3Config
|
|
||||||
if ($EndpointUrl) { $cfg.ServiceURL = $EndpointUrl }
|
|
||||||
if ($Region) { try { $cfg.RegionEndpoint = [Amazon.RegionEndpoint]::GetBySystemName($Region) } catch {} }
|
|
||||||
$cfg.ForcePathStyle = [bool]$ForcePathStyle
|
|
||||||
return (New-Object Amazon.S3.AmazonS3Client($creds, $cfg))
|
|
||||||
}
|
|
||||||
|
|
||||||
function Get-Bytes([string]$s) { return [System.Text.Encoding]::UTF8.GetBytes($s) }
|
|
||||||
function Get-HashHex([byte[]]$bytes) {
|
|
||||||
if ($null -eq $bytes) { $bytes = [byte[]]@() }
|
|
||||||
$sha = [System.Security.Cryptography.SHA256]::Create()
|
|
||||||
try {
|
|
||||||
$ms = New-Object System.IO.MemoryStream -ArgumentList (,$bytes)
|
|
||||||
try {
|
|
||||||
$hash = $sha.ComputeHash([System.IO.Stream]$ms)
|
|
||||||
} finally { $ms.Dispose() }
|
|
||||||
return ([BitConverter]::ToString($hash)).Replace('-', '').ToLowerInvariant()
|
|
||||||
} finally { $sha.Dispose() }
|
|
||||||
}
|
|
||||||
function HmacSha256([byte[]]$key, [string]$data) {
|
|
||||||
$h = [System.Security.Cryptography.HMACSHA256]::new($key)
|
|
||||||
try {
|
|
||||||
$b = [System.Text.Encoding]::UTF8.GetBytes($data)
|
|
||||||
$ms = New-Object System.IO.MemoryStream -ArgumentList (,$b)
|
|
||||||
try {
|
|
||||||
return $h.ComputeHash([System.IO.Stream]$ms)
|
|
||||||
} finally { $ms.Dispose() }
|
|
||||||
} finally { $h.Dispose() }
|
|
||||||
}
|
|
||||||
function GetSignatureKey([string]$secret, [string]$dateStamp, [string]$regionName, [string]$serviceName) {
|
|
||||||
$kDate = HmacSha256 (Get-Bytes ('AWS4' + $secret)) $dateStamp
|
|
||||||
$kRegion = HmacSha256 $kDate $regionName
|
|
||||||
$kService = HmacSha256 $kRegion $serviceName
|
|
||||||
HmacSha256 $kService 'aws4_request'
|
|
||||||
}
|
|
||||||
function UriEncode([string]$data, [bool]$encodeSlash) {
|
|
||||||
$enc = [System.Uri]::EscapeDataString($data)
|
|
||||||
if (-not $encodeSlash) { $enc = $enc -replace '%2F', '/' }
|
|
||||||
return $enc
|
|
||||||
}
|
|
||||||
function BuildCanonicalPath([System.Uri]$uri) {
|
|
||||||
$segments = $uri.AbsolutePath.Split('/')
|
|
||||||
$encoded = @()
|
|
||||||
foreach ($s in $segments) { $encoded += (UriEncode $s $false) }
|
|
||||||
$path = ($encoded -join '/')
|
|
||||||
if (-not $path.StartsWith('/')) { $path = '/' + $path }
|
|
||||||
return $path
|
|
||||||
}
|
|
||||||
function ToHex([byte[]]$b) { ([BitConverter]::ToString($b)).Replace('-', '').ToLowerInvariant() }
|
|
||||||
function BuildAuthHeaders($method, [System.Uri]$uri, [string]$region, [string]$accessKey, [string]$secretKey, [string]$payloadHash) {
|
|
||||||
$algorithm = 'AWS4-HMAC-SHA256'
|
|
||||||
$timestamp = (Get-Date).ToUniversalTime()
|
|
||||||
$amzDate = $timestamp.ToString('yyyyMMddTHHmmssZ')
|
|
||||||
$dateStamp = $timestamp.ToString('yyyyMMdd')
|
|
||||||
$hostHeader = $uri.Host
|
|
||||||
if (-not $uri.IsDefaultPort) { $hostHeader = "${hostHeader}:$($uri.Port)" }
|
|
||||||
$canonicalUri = BuildCanonicalPath $uri
|
|
||||||
$canonicalQueryString = ''
|
|
||||||
$canonicalHeaders = "host:$hostHeader`n" + "x-amz-content-sha256:$payloadHash`n" + "x-amz-date:$amzDate`n"
|
|
||||||
$signedHeaders = 'host;x-amz-content-sha256;x-amz-date'
|
|
||||||
$canonicalRequest = "$method`n$canonicalUri`n$canonicalQueryString`n$canonicalHeaders`n$signedHeaders`n$payloadHash"
|
|
||||||
$credentialScope = "$dateStamp/$region/s3/aws4_request"
|
|
||||||
$stringToSign = "$algorithm`n$amzDate`n$credentialScope`n$((Get-HashHex (Get-Bytes $canonicalRequest)))"
|
|
||||||
$signingKey = GetSignatureKey $secretKey $dateStamp $region 's3'
|
|
||||||
$signature = ToHex (HmacSha256 $signingKey $stringToSign)
|
|
||||||
$authHeader = "$algorithm Credential=$accessKey/$credentialScope, SignedHeaders=$signedHeaders, Signature=$signature"
|
|
||||||
@{
|
|
||||||
'x-amz-date' = $amzDate
|
|
||||||
'x-amz-content-sha256' = $payloadHash
|
|
||||||
'Authorization' = $authHeader
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function BuildS3Uri([string]$endpointUrl, [string]$bucket, [string]$key, [bool]$forcePathStyle) {
|
|
||||||
$base = [System.Uri]$endpointUrl
|
|
||||||
$builder = [System.UriBuilder]::new($base)
|
|
||||||
$normalizedKey = $key.Replace('\', '/').TrimStart('/')
|
|
||||||
if ($forcePathStyle) {
|
|
||||||
$path = $builder.Path.TrimEnd('/')
|
|
||||||
if ([string]::IsNullOrEmpty($path)) { $path = '/' }
|
|
||||||
$builder.Path = ($path.TrimEnd('/') + '/' + $bucket + '/' + $normalizedKey)
|
|
||||||
} else {
|
|
||||||
$builder.Host = "$bucket." + $builder.Host
|
|
||||||
$path = $builder.Path.TrimEnd('/')
|
|
||||||
if ([string]::IsNullOrEmpty($path)) { $path = '/' }
|
|
||||||
$builder.Path = ($path.TrimEnd('/') + '/' + $normalizedKey)
|
|
||||||
}
|
|
||||||
return $builder.Uri
|
|
||||||
}
|
|
||||||
|
|
||||||
function Invoke-S3HttpDownloadWithRetry {
|
|
||||||
param(
|
|
||||||
[string]$EndpointUrl,
|
|
||||||
[string]$Bucket,
|
|
||||||
[string]$Key,
|
|
||||||
[string]$TargetPath,
|
|
||||||
[string]$Region,
|
|
||||||
[string]$AccessKeyId,
|
|
||||||
[string]$SecretAccessKey,
|
|
||||||
[bool]$ForcePathStyle,
|
|
||||||
[string]$Activity
|
|
||||||
)
|
|
||||||
|
|
||||||
Add-Type -AssemblyName System.Net.Http -ErrorAction SilentlyContinue
|
|
||||||
[System.Net.Http.HttpClient]$client = [System.Net.Http.HttpClient]::new()
|
|
||||||
$retries = 5
|
|
||||||
$delay = 2
|
|
||||||
try {
|
|
||||||
for ($attempt = 0; $attempt -lt $retries; $attempt++) {
|
|
||||||
$request = $null
|
|
||||||
try {
|
|
||||||
$uri = BuildS3Uri -endpointUrl $EndpointUrl -bucket $Bucket -key $Key -forcePathStyle $ForcePathStyle
|
|
||||||
$payloadHash = (Get-HashHex (Get-Bytes ''))
|
|
||||||
$headers = BuildAuthHeaders -method 'GET' -uri $uri -region $Region -accessKey $AccessKeyId -secretKey $SecretAccessKey -payloadHash $payloadHash
|
|
||||||
$request = [System.Net.Http.HttpRequestMessage]::new([System.Net.Http.HttpMethod]::Get, $uri)
|
|
||||||
foreach ($kvp in $headers.GetEnumerator()) {
|
|
||||||
$request.Headers.TryAddWithoutValidation($kvp.Key, $kvp.Value) | Out-Null
|
|
||||||
}
|
|
||||||
|
|
||||||
$response = $client.SendAsync($request, [System.Net.Http.HttpCompletionOption]::ResponseHeadersRead).GetAwaiter().GetResult()
|
|
||||||
$null = $response.EnsureSuccessStatusCode()
|
|
||||||
|
|
||||||
$totalBytes = $response.Content.Headers.ContentLength
|
|
||||||
$stream = $response.Content.ReadAsStreamAsync().Result
|
|
||||||
$tmpPath = $TargetPath
|
|
||||||
$fs = [System.IO.File]::Create($tmpPath)
|
|
||||||
try {
|
|
||||||
$buffer = New-Object byte[] 8192
|
|
||||||
$totalRead = 0
|
|
||||||
while (($read = $stream.Read($buffer, 0, $buffer.Length)) -gt 0) {
|
|
||||||
$fs.Write($buffer, 0, $read)
|
|
||||||
$totalRead += $read
|
|
||||||
if ($totalBytes) {
|
|
||||||
$pct = ($totalRead * 100.0) / $totalBytes
|
|
||||||
Write-Progress -Activity $Activity -Status ("{0:N2}% Complete" -f $pct) -PercentComplete $pct
|
|
||||||
} else {
|
|
||||||
Write-Progress -Activity $Activity -Status ("Downloaded {0:N0} bytes" -f $totalRead) -PercentComplete 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
$fs.Close()
|
|
||||||
$stream.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($response) { $response.Dispose() }
|
|
||||||
Write-Progress -Activity $Activity -Completed -Status 'Completed'
|
|
||||||
return
|
|
||||||
} catch {
|
|
||||||
if ($attempt -lt ($retries - 1)) {
|
|
||||||
Write-Warning "Download of '$Key' failed (attempt $($attempt + 1)/$retries): $($_.Exception.Message). Retrying in ${delay}s..."
|
|
||||||
Start-Sleep -Seconds $delay
|
|
||||||
$delay = [Math]::Min($delay * 2, 30)
|
|
||||||
} else {
|
|
||||||
throw
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if ($request) { $request.Dispose() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
$client.Dispose()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function Invoke-DownloadWithRetry {
|
function Invoke-DownloadWithRetry {
|
||||||
param(
|
param(
|
||||||
[System.Net.Http.HttpClient]$Client,
|
[System.Net.Http.HttpClient]$Client,
|
||||||
@@ -342,6 +119,79 @@ function Invoke-DownloadWithRetry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Invoke-S3HttpDownloadWithRetry {
|
||||||
|
param(
|
||||||
|
[string]$EndpointUrl,
|
||||||
|
[string]$Bucket,
|
||||||
|
[string]$Key,
|
||||||
|
[string]$TargetPath,
|
||||||
|
[string]$Region,
|
||||||
|
[string]$AccessKeyId,
|
||||||
|
[string]$SecretAccessKey,
|
||||||
|
[bool]$ForcePathStyle,
|
||||||
|
[string]$Activity
|
||||||
|
)
|
||||||
|
|
||||||
|
Add-Type -AssemblyName System.Net.Http -ErrorAction SilentlyContinue
|
||||||
|
[System.Net.Http.HttpClient]$client = [System.Net.Http.HttpClient]::new()
|
||||||
|
$retries = 5
|
||||||
|
$delay = 2
|
||||||
|
try {
|
||||||
|
for ($attempt = 0; $attempt -lt $retries; $attempt++) {
|
||||||
|
$request = $null
|
||||||
|
try {
|
||||||
|
$uri = BuildS3Uri -endpointUrl $EndpointUrl -bucket $Bucket -key $Key -forcePathStyle $ForcePathStyle
|
||||||
|
$payloadHash = (Get-HashHex (Get-Bytes ''))
|
||||||
|
$headers = BuildAuthHeaders -method 'GET' -uri $uri -region $Region -accessKey $AccessKeyId -secretKey $SecretAccessKey -payloadHash $payloadHash
|
||||||
|
$request = [System.Net.Http.HttpRequestMessage]::new([System.Net.Http.HttpMethod]::Get, $uri)
|
||||||
|
foreach ($kvp in $headers.GetEnumerator()) {
|
||||||
|
$request.Headers.TryAddWithoutValidation($kvp.Key, $kvp.Value) | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = $client.SendAsync($request, [System.Net.Http.HttpCompletionOption]::ResponseHeadersRead).GetAwaiter().GetResult()
|
||||||
|
$null = $response.EnsureSuccessStatusCode()
|
||||||
|
|
||||||
|
$totalBytes = $response.Content.Headers.ContentLength
|
||||||
|
$stream = $response.Content.ReadAsStreamAsync().Result
|
||||||
|
$fs = [System.IO.File]::Create($TargetPath)
|
||||||
|
try {
|
||||||
|
$buffer = New-Object byte[] 8192
|
||||||
|
$totalRead = 0
|
||||||
|
while (($read = $stream.Read($buffer, 0, $buffer.Length)) -gt 0) {
|
||||||
|
$fs.Write($buffer, 0, $read)
|
||||||
|
$totalRead += $read
|
||||||
|
if ($totalBytes) {
|
||||||
|
$pct = ($totalRead * 100.0) / $totalBytes
|
||||||
|
Write-Progress -Activity $Activity -Status ("{0:N2}% Complete" -f $pct) -PercentComplete $pct
|
||||||
|
} else {
|
||||||
|
Write-Progress -Activity $Activity -Status ("Downloaded {0:N0} bytes" -f $totalRead) -PercentComplete 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
$fs.Close()
|
||||||
|
$stream.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($response) { $response.Dispose() }
|
||||||
|
Write-Progress -Activity $Activity -Completed -Status 'Completed'
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
if ($attempt -lt ($retries - 1)) {
|
||||||
|
Write-Warning "Download of '$Key' failed (attempt $($attempt + 1)/$retries): $($_.Exception.Message). Retrying in ${delay}s..."
|
||||||
|
Start-Sleep -Seconds $delay
|
||||||
|
$delay = [Math]::Min($delay * 2, 30)
|
||||||
|
} else {
|
||||||
|
throw
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if ($request) { $request.Dispose() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
$client.Dispose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function Get-FileSha256Lower {
|
function Get-FileSha256Lower {
|
||||||
param([string]$Path)
|
param([string]$Path)
|
||||||
if (-not (Test-Path -LiteralPath $Path)) { throw "File not found: $Path" }
|
if (-not (Test-Path -LiteralPath $Path)) { throw "File not found: $Path" }
|
||||||
@@ -387,19 +237,6 @@ function Get-RelativePath {
|
|||||||
return $relativePath.Replace('/', [System.IO.Path]::DirectorySeparatorChar)
|
return $relativePath.Replace('/', [System.IO.Path]::DirectorySeparatorChar)
|
||||||
}
|
}
|
||||||
|
|
||||||
function Combine-StoragePath {
|
|
||||||
param(
|
|
||||||
[string]$Prefix,
|
|
||||||
[string]$Name
|
|
||||||
)
|
|
||||||
|
|
||||||
$cleanName = $Name.Replace('\', '/').TrimStart('/')
|
|
||||||
if ([string]::IsNullOrWhiteSpace($Prefix)) { return $cleanName }
|
|
||||||
$normalizedPrefix = $Prefix.Replace('\', '/').Trim('/')
|
|
||||||
if ([string]::IsNullOrEmpty($normalizedPrefix)) { return $cleanName }
|
|
||||||
return "$normalizedPrefix/$cleanName"
|
|
||||||
}
|
|
||||||
|
|
||||||
function Load-Manifest {
|
function Load-Manifest {
|
||||||
param([string]$Path)
|
param([string]$Path)
|
||||||
$raw = Get-Content -LiteralPath $Path -Encoding UTF8 -Raw
|
$raw = Get-Content -LiteralPath $Path -Encoding UTF8 -Raw
|
||||||
@@ -542,7 +379,7 @@ function Update-KHDB {
|
|||||||
)
|
)
|
||||||
Start-UpdateTranscript -BasePath $scriptRoot
|
Start-UpdateTranscript -BasePath $scriptRoot
|
||||||
try {
|
try {
|
||||||
$settings = Read-ElysiumSettings
|
$settings = Read-ElysiumSettings -ScriptRoot $scriptRoot
|
||||||
$installPath = Get-InstallationPath $settings
|
$installPath = Get-InstallationPath $settings
|
||||||
Ensure-Directory $installPath
|
Ensure-Directory $installPath
|
||||||
|
|
||||||
@@ -558,10 +395,10 @@ function Update-KHDB {
|
|||||||
$parallelS3DownloadHelperList = @()
|
$parallelS3DownloadHelperList = @()
|
||||||
if ($parallelDownloadsEnabled) {
|
if ($parallelDownloadsEnabled) {
|
||||||
$parallelAzureDownloadHelpers = @{
|
$parallelAzureDownloadHelpers = @{
|
||||||
'Build-BlobUri' = Get-FunctionDefinitionText 'Build-BlobUri'
|
'Build-BlobUri' = Get-FunctionDefinitionText 'Build-BlobUri'
|
||||||
'Invoke-DownloadWithRetry' = Get-FunctionDefinitionText 'Invoke-DownloadWithRetry'
|
'Invoke-DownloadWithRetry' = Get-FunctionDefinitionText 'Invoke-DownloadWithRetry'
|
||||||
'New-HttpClient' = Get-FunctionDefinitionText 'New-HttpClient'
|
'New-HttpClient' = Get-FunctionDefinitionText 'New-HttpClient'
|
||||||
'Get-FileSha256Lower' = Get-FunctionDefinitionText 'Get-FileSha256Lower'
|
'Get-FileSha256Lower' = Get-FunctionDefinitionText 'Get-FileSha256Lower'
|
||||||
}
|
}
|
||||||
$parallelAzureDownloadHelperList = $parallelAzureDownloadHelpers.GetEnumerator() | ForEach-Object {
|
$parallelAzureDownloadHelperList = $parallelAzureDownloadHelpers.GetEnumerator() | ForEach-Object {
|
||||||
[pscustomobject]@{ Name = $_.Key; Definition = $_.Value }
|
[pscustomobject]@{ Name = $_.Key; Definition = $_.Value }
|
||||||
@@ -727,14 +564,14 @@ function Update-KHDB {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($needsDownload) {
|
if ($needsDownload) {
|
||||||
[void]$downloadQueue.Add([pscustomobject]@{
|
[void]$downloadQueue.Add([pscustomobject]@{
|
||||||
Name = $name
|
Name = $name
|
||||||
Sha256 = $expectedHash
|
Sha256 = $expectedHash
|
||||||
Size = $expectedSize
|
Size = $expectedSize
|
||||||
})
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if ($downloadQueue.Count -gt 0) {
|
if ($downloadQueue.Count -gt 0) {
|
||||||
Write-Host ("{0} shard(s) require download or refresh." -f $downloadQueue.Count)
|
Write-Host ("{0} shard(s) require download or refresh." -f $downloadQueue.Count)
|
||||||
@@ -763,12 +600,12 @@ function Update-KHDB {
|
|||||||
$storageClient = New-S3Client -EndpointUrl $s3EndpointUrl -Region $s3Region -AccessKeyId $s3AK -SecretAccessKey $s3SK -ForcePathStyle:$forcePathStyle
|
$storageClient = New-S3Client -EndpointUrl $s3EndpointUrl -Region $s3Region -AccessKeyId $s3AK -SecretAccessKey $s3SK -ForcePathStyle:$forcePathStyle
|
||||||
}
|
}
|
||||||
$storageHttpClient = @{
|
$storageHttpClient = @{
|
||||||
Endpoint = $s3EndpointUrl
|
Endpoint = $s3EndpointUrl
|
||||||
Bucket = $s3Bucket
|
Bucket = $s3Bucket
|
||||||
Region = $s3Region
|
Region = $s3Region
|
||||||
AccessKey = $s3AK
|
AccessKey = $s3AK
|
||||||
SecretKey = $s3SK
|
SecretKey = $s3SK
|
||||||
ForcePath = $forcePathStyle
|
ForcePath = $forcePathStyle
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -846,11 +683,11 @@ function Update-KHDB {
|
|||||||
$downloadIndex = 0
|
$downloadIndex = 0
|
||||||
foreach ($entry in $downloadQueue.ToArray()) {
|
foreach ($entry in $downloadQueue.ToArray()) {
|
||||||
$downloadIndex++
|
$downloadIndex++
|
||||||
if ($null -eq $entry) { continue }
|
if ($null -eq $entry) { continue }
|
||||||
$name = [string]$entry.Name
|
$name = [string]$entry.Name
|
||||||
if ([string]::IsNullOrWhiteSpace($name)) {
|
if ([string]::IsNullOrWhiteSpace($name)) {
|
||||||
throw "Shard entry missing name: $(ConvertTo-Json $entry -Compress)"
|
throw "Shard entry missing name: $(ConvertTo-Json $entry -Compress)"
|
||||||
}
|
}
|
||||||
$expectedHash = ([string]$entry.Sha256).ToLowerInvariant()
|
$expectedHash = ([string]$entry.Sha256).ToLowerInvariant()
|
||||||
$expectedSize = [long]$entry.Size
|
$expectedSize = [long]$entry.Size
|
||||||
|
|
||||||
@@ -862,7 +699,7 @@ function Update-KHDB {
|
|||||||
if ($isS3) {
|
if ($isS3) {
|
||||||
if ($storageClient) {
|
if ($storageClient) {
|
||||||
try {
|
try {
|
||||||
$request = New-Object Amazon.S3.Model.GetObjectRequest -Property @{ BucketName = $s3Bucket; Key = $remoteKey }
|
$request = New-Object Amazon.S3.Model.GetObjectRequest -Property @{ BucketName = $s3Bucket; Key = $remoteKey }
|
||||||
$response = $storageClient.GetObject($request)
|
$response = $storageClient.GetObject($request)
|
||||||
try { $response.WriteResponseStreamToFile($stagingPath, $true) } finally { $response.Dispose() }
|
try { $response.WriteResponseStreamToFile($stagingPath, $true) } finally { $response.Dispose() }
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
##################################################
|
##################################################
|
||||||
## Project: Elysium ##
|
## Project: Elysium ##
|
||||||
## File: Update-LithnetStore.ps1 ##
|
## File: Update-LithnetStore.ps1 ##
|
||||||
## Version: 2.2.0 ##
|
## Version: 2.4.2 ##
|
||||||
## Support: support@cqre.net ##
|
## Support: support@cqre.net ##
|
||||||
##################################################
|
##################################################
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user