feat: checkpoint reviewed native registry SACL recovery

This commit is contained in:
Shirofune-Security committed 2026-09-22 10:11:02 +09:00
1 parent 03039cb1c6
commit 530b49f26b
7 files changed
+592

No files matched your search

+7
View File
@@ -81,3 +81,10 @@ tests/SelectedSaclFixtureProtection.cs text eol=lf
# Reviewed channel restoration binds exact installed source bytes.
/scripts/ChannelRecovery.ps1 text eol=lf
/tests/ChannelRecovery*.ps1 text eol=lf
# Registry recovery plans bind identical source bytes across native hosts.
/scripts/RegistrySaclRecovery* text eol=lf
/tests/RegistrySaclRecovery* text eol=lf
/scripts/SelectedSaclDescendants.ps1 text eol=lf
/scripts/AuditRecovery.ps1 text eol=lf
/scripts/EvtxRecovery.ps1 text eol=lf
@@ -0,0 +1,43 @@
name: Native reviewed registry SACL recovery
on:
push:
branches: ['**']
paths:
- 'tests/RegistrySacl*'
- 'scripts/RegistrySaclRecovery*'
- 'scripts/SelectedSacl*'
- 'scripts/TargetedSaclPlanning.ps1'
- 'WELA.ps1'
- '.github/workflows/registry-sacl-recovery.yml'
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
registry-sacl-recovery:
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
os: [windows-2022, windows-2025]
engine: [powershell, pwsh]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
- name: Actual public registry lifecycle in Windows PowerShell 5.1
if: matrix.engine == 'powershell'
shell: powershell
run: |
./tests/RegistrySaclRecovery.Windows.Tests.ps1 -AllowDisposableHiveWrite
- name: Actual public registry lifecycle in PowerShell 7
if: matrix.engine == 'pwsh'
shell: pwsh
run: |
./tests/RegistrySaclRecovery.Windows.Tests.ps1 -AllowDisposableHiveWrite
- name: Retain public receipts, actual XML and exact cleanup
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
with:
name: registry-sacl-recovery-${{ matrix.os }}-${{ matrix.engine }}
path: ${{ runner.temp }}/wela-registry-recovery-*/
if-no-files-found: error
+25
View File
@@ -104,6 +104,16 @@
[ValidateSet('Plan','Run')][string]$ProbeAction = 'Plan',
[string]$ProbeOutputPath,
[ValidateRange(1,30)][int]$ProbeTimeoutSeconds = 15,
[ValidateSet('Plan','Restore')][string]$RegistryRecoveryAction = 'Plan',
[string]$RegistryRecoveryOriginalPlanPath,
[string]$RegistryRecoveryPendingPath,
[string]$RegistryRecoveryConfirmedPath,
[string]$RegistryRecoveryOriginalResultsPath,
[string]$RegistryRecoveryPlanPath,
[string]$RegistryRecoveryPlanHash,
[string]$RegistryRecoveryOutputPath,
[switch]$RegistryRecoveryAllowAuditReduction,
[switch]$RegistryRecoveryAllowInheritance,
[ValidateSet('Audit','Plan','Configure')][string]$TargetSaclAction = 'Audit',
[string]$TargetSaclProfile,
[string[]]$TargetSaclId,
@@ -272,6 +282,7 @@ Import-Module (Join-Path $ScriptRoot "modules/WefSubscriptions.psm1") -ErrorActi
. (Join-Path $ScriptRoot "scripts/AuditScoring.ps1")
. (Join-Path $ScriptRoot "scripts/TargetedSaclPlanning.ps1")
. (Join-Path $ScriptRoot "scripts/SelectedSaclConfiguration.ps1")
. (Join-Path $ScriptRoot "scripts/RegistrySaclRecovery.ps1")
. (Join-Path $ScriptRoot "scripts/GpoAuditPackages.ps1")
. (Join-Path $ScriptRoot "scripts/IntuneAuditExport.ps1")
. (Join-Path $ScriptRoot "scripts/EvtxRecovery.ps1")
@@ -1982,6 +1993,7 @@ Remove these options from automation wrappers; check WELA's exit code instead.
Usage:
./WELA.ps1 dns-analytical -Help # Dedicated DNS Server direct-channel lifecycle
./WELA.ps1 wec-runtime -WecRuntimeId subscription-id -ResultsPath new-runtime.json
./WELA.ps1 registry-sacl-recovery -Help # Reviewed removal of one proven registry audit ACE
./WELA.ps1 targeted-sacl -Help # Selected existing local SACL targets; read-only by default
./WELA.ps1 gpo-create -Help # Create only a new disabled, unlinked GPO from reviewed genuine backup
./WELA.ps1 gpo-package -GpoAction Plan -GpoProfile wela-2.2.0 -Role Client -Build 26100
@@ -2100,6 +2112,12 @@ if ($Cmd -ne 'dns-analytical' -and @($PSBoundParameters.Keys | Where-Object { $_
if ($Cmd -eq 'dns-analytical' -and @($PSBoundParameters.Keys | Where-Object { $_ -notin @('Cmd','DnsAction','DnsState','DnsRetention','DnsMinimumBytes','DnsArchiveMaximumBytes','AllowDnsTraceReset','Auto','DryRun','BackupPath','ResultsPath','Help') }).Count) {
throw 'dns-analytical accepts only dedicated DNS lifecycle and report options. No command was run.'
}
if ($Cmd -ne 'registry-sacl-recovery' -and @($PSBoundParameters.Keys | Where-Object { $_ -like 'RegistryRecovery*' }).Count) {
throw 'RegistryRecovery options require registry-sacl-recovery. No command was run.'
}
if ($Cmd -eq 'registry-sacl-recovery' -and ($args.Count -or @($PSBoundParameters.Keys | Where-Object { $_ -notin @('Cmd','RegistryRecoveryAction','RegistryRecoveryOriginalPlanPath','RegistryRecoveryPendingPath','RegistryRecoveryConfirmedPath','RegistryRecoveryOriginalResultsPath','RegistryRecoveryPlanPath','RegistryRecoveryPlanHash','RegistryRecoveryOutputPath','RegistryRecoveryAllowAuditReduction','RegistryRecoveryAllowInheritance','Help') }).Count)) {
throw 'registry-sacl-recovery accepts only its dedicated options. Use read-only Plan before explicitly consented Restore.'
}
if ($Cmd -ne 'targeted-sacl' -and @($PSBoundParameters.Keys | Where-Object { $_ -like 'TargetSacl*' }).Count) {
throw 'TargetSacl options require targeted-sacl. No command was run.'
}
@@ -2318,6 +2336,13 @@ switch ($Cmd.ToLower()) {
$report
if ($report.ExitCode) {exit $report.ExitCode}
}
'registry-sacl-recovery' {
if ($Help) { Write-Host 'Usage: ./WELA.ps1 registry-sacl-recovery -RegistryRecoveryOriginalPlanPath original-plan.json -RegistryRecoveryPendingPath target.pending.json -RegistryRecoveryConfirmedPath target.confirmed.json -RegistryRecoveryOriginalResultsPath original-results.json -RegistryRecoveryOutputPath new-review-directory. Restore: -RegistryRecoveryAction Restore -RegistryRecoveryPlanPath review/plan.json -RegistryRecoveryPlanHash sha256 -RegistryRecoveryOutputPath new-evidence-directory -RegistryRecoveryAllowAuditReduction -RegistryRecoveryAllowInheritance. One proven registry-root audit ACE only; historical/current descendants must be empty. Value/child/descriptor drift refuses. Pending evidence may describe a partial removal; no automatic rollback or atomic-tree guarantee. See docs/registry-sacl-recovery.md.'; return }
$report=Invoke-WelaRegistrySaclRecovery -Action $RegistryRecoveryAction -OriginalPlanPath $RegistryRecoveryOriginalPlanPath -PendingPath $RegistryRecoveryPendingPath -ConfirmedPath $RegistryRecoveryConfirmedPath -OriginalResultsPath $RegistryRecoveryOriginalResultsPath -PlanPath $RegistryRecoveryPlanPath -PlanHash $RegistryRecoveryPlanHash -OutputPath $RegistryRecoveryOutputPath -AllowAuditReduction:$RegistryRecoveryAllowAuditReduction -AllowInheritance:$RegistryRecoveryAllowInheritance
$report
if ($report.ExitCode -ne 0) { exit $report.ExitCode }
return
}
'targeted-sacl' {
if ($Help) { Write-Host 'Usage: ./WELA.ps1 targeted-sacl -TargetSaclProfile profile-id [-TargetSaclId id,...] [-TargetSaclAction Audit|Plan] [-IncludeOptional] [-TargetSaclIncludeChildren] [-ResultsPath new-plan.json]. Configure requires -TargetSaclAction Configure -TargetSaclPlanPath reviewed.json -TargetSaclId same-ids [-TargetSaclIncludeChildren] [-IncludeOptional] [-DryRun] [-Auto] [-BackupPath new-directory] [-ResultsPath new-results.json]. Existing local targets only; IncludeChildren requires a complete reviewed capture of at most 128 descendants per root, depth 16. See docs/selected-sacl-configuration.md.'; return }
$report=Invoke-WelaSelectedSacl -Action $TargetSaclAction -Profile $TargetSaclProfile -Ids $TargetSaclId -PlanPath $TargetSaclPlanPath -IncludeOptional:$IncludeOptional -IncludeChildren:$TargetSaclIncludeChildren -DryRun:$DryRun -Auto:$Auto -BackupPath $BackupPath -ResultsPath $ResultsPath
+183
View File
@@ -0,0 +1,183 @@
# One proven selected-root audit ACE, only with empty historical/current descendants.
function Get-WelaRegistryRecoveryKey {param($Value) ConvertTo-Json -InputObject $Value -Depth 32 -Compress}
function Assert-WelaRegistryRecoveryText {param($Value,[string[]]$Names) foreach($name in $Names){if($Value.$name -isnot [string]){throw ('Missing or mistyped registry recovery text: '+$name)}}}
function Assert-WelaRegistryRecoveryNumber {param($Value) if($Value -isnot [int] -and $Value -isnot [long]){throw 'Registry recovery requires an integer.'}}
function Initialize-WelaRegistryRecoveryNative {
$bytes=[IO.File]::ReadAllBytes((Join-Path $PSScriptRoot 'RegistrySaclRecoveryNative.cs'));$hash=Get-WelaArrivalHash $bytes
if(-not ('Wela.RegistrySaclRecovery.Descriptor' -as [type])){
$source=[Text.UTF8Encoding]::new($false,$true).GetString($bytes).TrimStart([char]0xfeff);$marker='__WELA_REGISTRY_SACL_RECOVERY_SOURCE_SHA256__'
if(($source.Split(@($marker),[StringSplitOptions]::None)).Count -ne 2){throw 'Unexpected native registry recovery source binding.'}
Add-Type -TypeDefinition $source.Replace($marker,$hash) -ErrorAction Stop
}
if([Wela.RegistrySaclRecovery.Descriptor]::SourceSha256 -cne $hash){throw 'Loaded registry recovery code differs; start a fresh PowerShell process.'}
}
function Get-WelaRegistryRecoverySources {
$sources=[ordered]@{}
foreach($name in @('WELA.ps1','scripts/RegistrySaclRecovery.ps1','scripts/RegistrySaclRecoveryNative.cs','scripts/SelectedSaclConfiguration.ps1','scripts/SelectedSaclNative.cs','scripts/SelectedSaclDescendants.ps1','scripts/TargetedSaclPlanning.ps1','scripts/Configuration.ps1','scripts/ControlApplicability.ps1','scripts/CustomAuditProfiles.ps1','scripts/AuditRecovery.ps1','scripts/EvtxRecovery.ps1','scripts/WefArrival.ps1','scripts/WecUpdate.ps1','modules/AuditProfiles.psm1','modules/AuditCatalog.psm1','modules/NativeProviders.psm1','config/audit_profiles.json','config/audit_sacl_targets.json','config/control_applicability.json','config/baselines.json')){
$sources[$name]=(Get-FileHash -LiteralPath (Join-Path $script:ScriptRoot $name) -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant()
}
[pscustomobject]$sources
}
function Get-WelaRegistryRecoveryContext {
if([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT -or -not [Environment]::Is64BitProcess){throw 'Registry SACL recovery requires native 64-bit Windows.'}
foreach($name in @('Winmgmt','EventLog')){if((Get-Service -Name $name -ErrorAction Stop).Status -ne 'Running'){throw 'Native observation services must already be running.'}}
Initialize-WelaRegistryRecoveryNative
$token=[Wela.RegistrySaclRecovery.TokenReader]::Snapshot();$identity=[Security.Principal.WindowsIdentity]::GetCurrent()
try{if(-not ([Security.Principal.WindowsPrincipal]::new($identity)).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){throw 'Registry SACL recovery requires an elevated operator.'}}finally{$identity.Dispose()}
$actualMasks=Get-WelaEffectiveAuditPolicy;$masks=[ordered]@{};foreach($id in @($actualMasks.Keys|Sort-Object)){$masks[$id]=$actualMasks[$id]}
[pscustomobject][ordered]@{Host=(Get-WelaRecoveryHost);Selected=(Get-WelaSelectedSaclContext);Token=$token;AuditMasks=$masks;Precedence=(Get-WelaRegistryState 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' SCENoApplyLegacyAuditPolicy)}
}
function Read-WelaRegistryRecoveryInput {
param([string]$Path)
$file=Read-WelaWecUpdateFile $Path 4194304
if(-not $file.Text){throw 'Original recovery evidence is empty.'}
[pscustomobject]@{Path=$file.Path;Sha256=$file.Hash;Data=(ConvertFrom-WelaEvtxJson $file.Text)}
}
function Assert-WelaRegistryRecoverySnapshot {
param($Snapshot,$Definition)
Assert-WelaEvtxObject $Snapshot @('Path','Kind','Identity','IsDirectory','DescriptorBase64','Owner','Group','DaclBase64','ControlFlags','SecurityInformation','DescriptorScope','Aces')
Assert-WelaRegistryRecoveryText $Snapshot @('Path','Kind','Identity','DescriptorBase64','DescriptorScope')
$path=Resolve-WelaSelectedSaclNativePath $Definition
if($Snapshot.Kind -cne 'Registry' -or $Snapshot.Path -cne $path -or $Snapshot.IsDirectory -isnot [bool] -or $Snapshot.IsDirectory -or $Snapshot.Identity -cnotmatch ('^'+[regex]::Escape($path)+':[0-9]+$') -or $Snapshot.Aces -isnot [array]){throw 'Only exact historical registry snapshots are supported.'}
foreach($name in @('ControlFlags','SecurityInformation')){Assert-WelaRegistryRecoveryNumber $Snapshot.$name}
foreach($ace in $Snapshot.Aces){
Assert-WelaEvtxObject $ace @('Binary','Type','Flags','Mask','Sid','Ordinary');Assert-WelaRegistryRecoveryText $ace @('Binary')
if($ace.Ordinary -isnot [bool] -or ($null -ne $ace.Sid -and $ace.Sid -isnot [string])){throw 'Mistyped historical ACE metadata.'}
foreach($name in @('Type','Flags','Mask')){Assert-WelaRegistryRecoveryNumber $ace.$name}
}
Initialize-WelaRegistryRecoveryNative
$parsed=[Wela.RegistrySaclRecovery.Descriptor]::Observe($Snapshot.Path,$Snapshot.Identity,[Convert]::FromBase64String($Snapshot.DescriptorBase64))
if((Get-WelaSelectedSaclSnapshotKey $parsed) -cne (Get-WelaSelectedSaclSnapshotKey $Snapshot)){throw 'Historical metadata differs from its native descriptor bytes.'}
}
function Assert-WelaRegistryRecoveryEmpty {
param($Inventory,$Root)
Assert-WelaEvtxObject $Inventory @('Status','Maximum','MaximumDepth','StartedUtc','CompletedUtc','Root','Entries','Diagnostics')
Assert-WelaRegistryRecoveryText $Inventory @('Status');Assert-WelaRegistryRecoveryNumber $Inventory.Maximum;Assert-WelaRegistryRecoveryNumber $Inventory.MaximumDepth
if($Inventory.Status -cne 'Complete' -or $Inventory.Maximum -ne 128 -or $Inventory.MaximumDepth -ne 16 -or $Inventory.Entries -isnot [array] -or $Inventory.Entries.Count -ne 0 -or $Inventory.Diagnostics -isnot [array] -or $Inventory.Diagnostics.Count -ne 0 -or (Get-WelaSelectedSaclSnapshotKey $Inventory.Root) -cne (Get-WelaSelectedSaclSnapshotKey $Root)){throw 'Recovery requires complete exact empty historical/current descendants.'}
$start=ConvertTo-WelaArrivalUtc $Inventory.StartedUtc;$end=ConvertTo-WelaArrivalUtc $Inventory.CompletedUtc
if($start -gt $end -or $end -gt [DateTimeOffset]::UtcNow.AddMinutes(1)){throw 'Descendant timestamps are invalid.'}
}
function Assert-WelaRegistryRecoveryDescendantOutcome {
param($Value)
Assert-WelaEvtxObject $Value @('Status','Scope','Ownership','Outcomes','Diagnostics');Assert-WelaRegistryRecoveryText $Value @('Status','Scope','Ownership')
if($Value.Status -cne 'Observed' -or $Value.Outcomes -isnot [array] -or $Value.Outcomes.Count -ne 0 -or $Value.Diagnostics -isnot [array] -or $Value.Diagnostics.Count -ne 0){throw 'Historical descendant verification is incomplete or nonempty.'}
}
function Get-WelaRegistryRecoverySnapshot {
param($Definition)
if($Definition.Kind -isnot [string] -or $Definition.Kind -cne 'Registry'){throw 'Only registry targets are supported.'}
Initialize-WelaRegistryRecoveryNative;$target=[Wela.RegistrySaclRecovery.Target]::new((Resolve-WelaSelectedSaclNativePath $Definition))
try{$target.Read()}finally{$target.Dispose()}
}
function Get-WelaRegistryRecoveryAddition {param($Before,$After,$Ace) Initialize-WelaRegistryRecoveryNative;[Wela.RegistrySaclRecovery.Descriptor]::AddedAce($Before.DescriptorBase64,$After.DescriptorBase64,$Ace.Sid,$Ace.Mask,$Ace.Flags)}
function New-WelaRegistryRecoveryPlan {
param([string]$OriginalPlanPath,[string]$PendingPath,[string]$ConfirmedPath,[string]$ResultsPath)
$context=Get-WelaRegistryRecoveryContext;$sources=Get-WelaRegistryRecoverySources
$files=[ordered]@{};foreach($entry in @(@('OriginalPlan',$OriginalPlanPath),@('Pending',$PendingPath),@('Confirmed',$ConfirmedPath),@('Results',$ResultsPath))){$files[$entry[0]]=Read-WelaRegistryRecoveryInput $entry[1]}
if(@($files.Values.Path|Sort-Object -Unique).Count -ne 4){throw 'Four distinct original evidence files are required.'}
$plan=$files.OriginalPlan.Data;$pending=$files.Pending.Data;$confirmed=$files.Confirmed.Data;$result=$files.Results.Data
$planFields=@('SchemaVersion','Kind','CapturedUtc','Profile','IncludeOptional','IncludeChildren','Context','Sources','Rows','GenerationReadiness','UsableRuleCredit','Catalog','UserInventory')
$rowFields=@('Id','DefinitionKey','Definition','Before','Ace','Status','Diagnostic','After','DescendantsBefore','DescendantsAfter','DescendantVerification')
Assert-WelaEvtxObject $plan $planFields
foreach($value in @($plan,$pending,$confirmed,$result)){Assert-WelaRegistryRecoveryNumber $value.SchemaVersion;if($value.SchemaVersion -ne 1){throw 'Unsupported original schema.'};Assert-WelaRegistryRecoveryText $value @('Kind')}
Assert-WelaRegistryRecoveryText $plan @('Profile','GenerationReadiness')
if($plan.Kind -cne 'WelaSelectedSaclPlan' -or $plan.IncludeChildren -isnot [bool] -or -not $plan.IncludeChildren -or $plan.IncludeOptional -isnot [bool] -or $plan.Rows -isnot [array] -or $plan.Rows.Count -ne 1 -or $plan.Catalog -isnot [array] -or $plan.Catalog.Count -ne 0){throw 'Require one original selected registry target with explicit child consent.'}
$row=$plan.Rows[0];Assert-WelaEvtxObject $row $rowFields;Assert-WelaRegistryRecoveryText $row @('Id','DefinitionKey','Status','Diagnostic')
Assert-WelaRegistryRecoveryText $row.Definition @('Kind','Path','Inheritance','Propagation')
if($row.Status -cne 'ChangeRequired' -or $row.Diagnostic -cne '' -or $null -ne $row.After -or $null -ne $row.DescendantsAfter -or $null -ne $row.DescendantVerification -or $row.Id -cnotmatch '^sacl-[a-f0-9]{24}$' -or $row.Definition.Kind -cne 'Registry' -or $row.Definition.Inheritance -cnotin @('None','ContainerInherit') -or $row.Definition.Propagation -cne 'None'){throw 'Original plan is not one supported registry root audit addition.'}
Assert-WelaSelectedSaclSources $plan.Sources
Assert-WelaRegistryRecoveryText $plan.Context @('Key','Computer')
if((Get-WelaRegistryRecoveryKey $plan.Context) -cne (Get-WelaRegistryRecoveryKey $context.Selected) -or $plan.Context.Computer -cne $context.Host.Computer){throw 'Original host context differs from the actual recovery host.'}
$catalog=Get-WelaSelectedSaclCatalog -Profile $plan.Profile -IncludeOptional:$plan.IncludeOptional -Context $context.Selected
$selection=@($catalog.Rows|Where-Object Id -CEQ $row.Id)
if($selection.Count -ne 1 -or $selection[0].DefinitionKey -cne $row.DefinitionKey -or (Get-WelaSelectedSaclDefinitionKey $row.Definition) -cne $row.DefinitionKey -or (Get-WelaRegistryRecoveryKey $selection[0].Definition) -cne (Get-WelaRegistryRecoveryKey $row.Definition)){throw 'Original target is not the exact currently source-bound catalog selection.'}
Assert-WelaRegistryRecoverySnapshot $row.Before $row.Definition;Assert-WelaRegistryRecoveryEmpty $row.DescendantsBefore $row.Before
$ace=Get-WelaSelectedSaclAce $row.Definition $row.Before -IncludeChildren
Assert-WelaEvtxObject $row.Ace @('Sid','Mask','Flags','RequiredPolicyMask');Assert-WelaRegistryRecoveryText $row.Ace @('Sid');foreach($name in @('Mask','Flags','RequiredPolicyMask')){Assert-WelaRegistryRecoveryNumber $row.Ace.$name}
if((Get-WelaRegistryRecoveryKey $ace) -cne (Get-WelaRegistryRecoveryKey $row.Ace) -or $ace.Flags -notin @(64,128,192,66,130,194) -or (Test-WelaSelectedSaclAce $row.Before $ace)){throw 'Original ACE is mistyped, inherited or already covered.'}
$receiptFields=@('SchemaVersion','Kind','State','RecordedUtc','Computer','ContextKey','Id','Sources','Definition','Before','Ace','After','DescendantsBefore','DescendantsAfter','DescendantVerification','Ownership')
foreach($receipt in @($pending,$confirmed)){
Assert-WelaEvtxObject $receipt $receiptFields;Assert-WelaRegistryRecoveryText $receipt @('Kind','State','Computer','ContextKey','Id','Ownership')
if($receipt.Kind -cne 'WelaSelectedSaclReceipt' -or $receipt.Computer -cne $context.Host.Computer -or $receipt.ContextKey -cne $context.Selected.Key -or $receipt.Id -cne $row.Id -or $receipt.Ownership -cne 'Only the verified explicit selected-root addition; never descendant ACE ownership or bulk rollback authority.'){throw 'Original receipt scope or ownership differs.'}
Assert-WelaSelectedSaclSources $receipt.Sources
foreach($name in @('Definition','Ace')){if((Get-WelaRegistryRecoveryKey $receipt.$name) -cne (Get-WelaRegistryRecoveryKey $row.$name)){throw 'Original receipt differs from selected plan.'}}
Assert-WelaRegistryRecoverySnapshot $receipt.Before $row.Definition
if((Get-WelaSelectedSaclSnapshotKey $receipt.Before) -cne (Get-WelaSelectedSaclSnapshotKey $row.Before)){throw 'Original before-state differs across records.'}
Assert-WelaRegistryRecoveryEmpty $receipt.DescendantsBefore $receipt.Before
}
if($pending.State -cne 'Pending' -or $null -ne $pending.After -or $null -ne $pending.DescendantsAfter -or $null -ne $pending.DescendantVerification -or $confirmed.State -cne 'Confirmed' -or $null -eq $confirmed.After -or (ConvertTo-WelaArrivalUtc $pending.RecordedUtc) -ne (ConvertTo-WelaArrivalUtc $confirmed.RecordedUtc)){throw 'A matching original Pending and Confirmed pair is required.'}
Assert-WelaRegistryRecoverySnapshot $confirmed.After $row.Definition;Assert-WelaRegistryRecoveryEmpty $confirmed.DescendantsAfter $confirmed.After;Assert-WelaRegistryRecoveryDescendantOutcome $confirmed.DescendantVerification
# Last-write metadata can change during the original SACL write; current state must match its exact observed After.
$added=Get-WelaRegistryRecoveryAddition $row.Before $confirmed.After $ace
Assert-WelaEvtxObject $result @('SchemaVersion','Kind','ExitCode','DryRun','BackupPath','Plan','Results','GenerationReadiness','UsableRuleCredit');Assert-WelaRegistryRecoveryNumber $result.ExitCode;Assert-WelaRegistryRecoveryText $result @('BackupPath','GenerationReadiness')
if($result.Kind -cne 'WelaSelectedSaclResult' -or $result.ExitCode -ne 0 -or $result.DryRun -isnot [bool] -or $result.DryRun -or $result.Results -isnot [array] -or $result.Results.Count -ne 1){throw 'Require one completed successful, non-dry-run operation.'}
$applied=$result.Results[0];Assert-WelaEvtxObject $applied $rowFields;Assert-WelaRegistryRecoveryText $applied @('Id','DefinitionKey','Status','Diagnostic')
Assert-WelaEvtxObject $result.Plan $planFields;Assert-WelaRegistryRecoveryText $result.Plan @('Kind')
if($applied.Status -cne 'Applied' -or $applied.Diagnostic -cne '' -or $result.Plan.Kind -cne 'WelaSelectedSaclPlan' -or $result.Plan.Rows -isnot [array] -or $result.Plan.Rows.Count -ne 1 -or (Get-WelaRegistryRecoveryKey $applied) -cne (Get-WelaRegistryRecoveryKey $result.Plan.Rows[0]) -or $applied.Id -cne $row.Id -or $applied.DefinitionKey -cne $row.DefinitionKey){throw 'Completed result status, rows or scope disagree.'}
foreach($name in @('Definition','Ace')){if((Get-WelaRegistryRecoveryKey $applied.$name) -cne (Get-WelaRegistryRecoveryKey $row.$name)){throw 'Completed selection differs from original plan.'}}
foreach($name in @('Before','After')){Assert-WelaRegistryRecoverySnapshot $applied.$name $row.Definition;if((Get-WelaSelectedSaclSnapshotKey $applied.$name) -cne (Get-WelaSelectedSaclSnapshotKey $confirmed.$name)){throw 'Completed descriptor evidence disagrees.'}}
Assert-WelaRegistryRecoveryEmpty $applied.DescendantsBefore $applied.Before;Assert-WelaRegistryRecoveryEmpty $applied.DescendantsAfter $applied.After;Assert-WelaRegistryRecoveryDescendantOutcome $applied.DescendantVerification
foreach($name in @('Profile','IncludeOptional','IncludeChildren','Context','Sources')){if((Get-WelaRegistryRecoveryKey $result.Plan.$name) -cne (Get-WelaRegistryRecoveryKey $plan.$name)){throw 'Completed plan context differs from original selection.'}}
$backup=Resolve-WelaArrivalPath $result.BackupPath
if($files.Pending.Path -ine (Join-Path $backup ($row.Id+'.pending.json')) -or $files.Confirmed.Path -ine (Join-Path $backup ($row.Id+'.confirmed.json'))){throw 'Receipt paths do not match recorded backup directory.'}
$originalTime=ConvertTo-WelaArrivalUtc $plan.CapturedUtc;$configuredTime=ConvertTo-WelaArrivalUtc $result.Plan.CapturedUtc;$receiptTime=ConvertTo-WelaArrivalUtc $pending.RecordedUtc
if($originalTime -gt $configuredTime -or $configuredTime -gt $receiptTime -or $receiptTime -gt [DateTimeOffset]::UtcNow.AddMinutes(1)){throw 'Original timestamps are reversed or in the future.'}
$current=Get-WelaRegistryRecoverySnapshot $row.Definition
if((Get-WelaSelectedSaclSnapshotKey $current) -cne (Get-WelaSelectedSaclSnapshotKey $confirmed.After)){throw 'Current registry last-write identity or full descriptor differs from completed After; value changes also require manual assessment.'}
$inputFiles=[ordered]@{};foreach($name in $files.Keys){$inputFiles[$name]=[pscustomobject]@{Path=$files[$name].Path;Sha256=$files[$name].Sha256}}
$recovery=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaRegistrySaclRecoveryPlan';Id=$row.Id;Context=$context;Sources=$sources;OriginalFiles=[pscustomobject]$inputFiles;Definition=$row.Definition;BeforeAddition=$row.Before;Expected=$current;AddedAce=$added;RequiresAuditReductionConsent=$true;RequiresInheritanceConsent=$true;HistoricalBinding='Original version1 records contain host context/source hashes, not authenticated historical operator identity. Registry path/last-write metadata cannot prove durable key identity.';Outcome='Remove one proven explicit registry-root audit ACE; preserve all other descriptor components and ACE order. Empty or null present SACL can remain. Empty-child observations are not an atomic tree guarantee.';ReadyRuleCredit=0}
Assert-WelaRegistryRecoveryFresh $recovery
$recovery
}
function Assert-WelaRegistryRecoveryBindings {
param($Plan)
if((Get-WelaRegistryRecoveryKey (Get-WelaRegistryRecoverySources)) -cne (Get-WelaRegistryRecoveryKey $Plan.Sources) -or (Get-WelaRegistryRecoveryKey (Get-WelaRegistryRecoveryContext)) -cne (Get-WelaRegistryRecoveryKey $Plan.Context)){throw 'Recovery source, host, logon, token or audit policy changed.'}
foreach($entry in $Plan.OriginalFiles.PSObject.Properties){if((Read-WelaRegistryRecoveryInput $entry.Value.Path).Sha256 -cne $entry.Value.Sha256){throw 'Original recovery evidence changed.'}}
}
function Assert-WelaRegistryRecoveryFresh {param($Plan) Assert-WelaRegistryRecoveryBindings $Plan;if((Get-WelaSelectedSaclSnapshotKey (Get-WelaRegistryRecoverySnapshot $Plan.Definition)) -cne (Get-WelaSelectedSaclSnapshotKey $Plan.Expected)){throw 'Reviewed registry identity/descriptor changed before removal.'}}
function Open-WelaRegistryRecoveryTarget {param($Definition) Initialize-WelaRegistryRecoveryNative;[Wela.RegistrySaclRecovery.Target]::new((Resolve-WelaSelectedSaclNativePath $Definition))}
function Invoke-WelaRegistrySaclRecovery {
param([ValidateSet('Plan','Restore')][string]$Action='Plan',[string]$OriginalPlanPath,[string]$PendingPath,[string]$ConfirmedPath,[string]$OriginalResultsPath,[string]$PlanPath,[string]$PlanHash,[string]$OutputPath,[switch]$AllowAuditReduction,[switch]$AllowInheritance)
$ErrorActionPreference='Stop'
if($Action -eq 'Plan'){
if(-not $OriginalPlanPath -or -not $PendingPath -or -not $ConfirmedPath -or -not $OriginalResultsPath -or -not $OutputPath -or $PlanPath -or $PlanHash -or $AllowAuditReduction -or $AllowInheritance){throw 'Plan requires four original evidence paths and a new output directory only.'}
}elseif(-not $PlanPath -or $PlanHash -cnotmatch '^[a-f0-9]{64}$' -or -not $OutputPath -or $OriginalPlanPath -or $PendingPath -or $ConfirmedPath -or $OriginalResultsPath){throw 'Restore requires only reviewed plan path/hash, new output and both explicit consents.'}
$output=New-WelaArrivalOutput $OutputPath $script:ScriptRoot
$report=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaRegistrySaclRecovery';Action=$Action;Status='Refused';ExitCode=1;PlanHash=$null;WriteAttempted=$false;Before=$null;After=$null;OriginalDescriptorBytesMatch=$false;Artifacts=@();OutputPath=$output;Diagnostic='';ReadyRuleCredit=0;PolicyChanges=0;Scope='One proven registry-root audit ACE; complete empty historical/current descendants only. No full descriptor rollback, historical key identity, atomic-tree, event or Sigma claim.'}
$target=$null
try {
if($Action -eq 'Plan'){
$plan=New-WelaRegistryRecoveryPlan $OriginalPlanPath $PendingPath $ConfirmedPath $OriginalResultsPath
$artifact=Write-WelaWecUpdateArtifact $output 'plan.json' (Get-WelaRegistryRecoveryKey $plan);$report.Artifacts+=$artifact;$report.PlanHash=$artifact.Sha256
Assert-WelaRegistryRecoveryFresh $plan
$report.Status='ReviewRequired';$report.ExitCode=0
}else{
$inputFile=Read-WelaRegistryRecoveryInput $PlanPath
if($inputFile.Sha256 -cne $PlanHash){throw 'Reviewed recovery plan hash differs.'}
$plan=$inputFile.Data;Assert-WelaRegistryRecoveryText $plan @('Kind')
if($plan.Kind -cne 'WelaRegistrySaclRecoveryPlan'){throw 'Unsupported recovery plan kind.'}
$inputs=$plan.OriginalFiles;$rebuilt=New-WelaRegistryRecoveryPlan $inputs.OriginalPlan.Path $inputs.Pending.Path $inputs.Confirmed.Path $inputs.Results.Path
if((Get-WelaRegistryRecoveryKey $plan) -cne (Get-WelaRegistryRecoveryKey $rebuilt)){throw 'Reviewed recovery plan is stale or modified.'}
if(-not $AllowAuditReduction -or -not $AllowInheritance){throw 'Restore requires explicit AllowAuditReduction and AllowInheritance: selected auditing is reduced and concurrent children can be affected.'}
$report.PlanHash=$PlanHash;$report.Before=$plan.Expected
$report.Artifacts+=Write-WelaWecUpdateArtifact $output 'reviewed-plan.json' (Get-WelaRegistryRecoveryKey $plan)
$report.Artifacts+=Write-WelaWecUpdateArtifact $output 'pending.json' (Get-WelaRegistryRecoveryKey ([pscustomobject]@{Kind='WelaRegistrySaclRecoveryIntent';State='Pending';PlanHash=$PlanHash;Before=$plan.Expected;RemoveAce=$plan.AddedAce;AllowAuditReduction=[bool]$AllowAuditReduction;AllowInheritance=[bool]$AllowInheritance;RecordedUtc=[DateTime]::UtcNow.ToString('o')}))
Assert-WelaRegistryRecoveryFresh $plan
if((Read-WelaRegistryRecoveryInput $PlanPath).Sha256 -cne $PlanHash){throw 'Reviewed recovery plan changed immediately before write.'}
foreach($artifact in $report.Artifacts){if((Get-FileHash -LiteralPath (Join-Path $output $artifact.Name) -Algorithm SHA256).Hash.ToLowerInvariant() -cne $artifact.Sha256){throw 'Saved recovery receipt changed before write.'}}
$target=Open-WelaRegistryRecoveryTarget $plan.Definition
try{$report.After=$target.Remove($plan.Expected.Identity,$plan.Expected.DescriptorBase64,$plan.AddedAce)}finally{$report.WriteAttempted=$target.WriteAttempted;if($target.AfterObservation){$report.After=$target.AfterObservation}}
$target.Dispose();$target=$null
if($null -ne $report.After){$report.Artifacts+=Write-WelaWecUpdateArtifact $output 'after.json' (Get-WelaRegistryRecoveryKey $report.After)}
Assert-WelaRegistryRecoveryBindings $plan
if((Get-WelaSelectedSaclSnapshotKey (Get-WelaRegistryRecoverySnapshot $plan.Definition)) -cne (Get-WelaSelectedSaclSnapshotKey $report.After)){throw 'Final registry identity/descriptor or empty-child state differs after removal.'}
if((Read-WelaRegistryRecoveryInput $PlanPath).Sha256 -cne $PlanHash){throw 'Reviewed plan changed after write.'}
foreach($artifact in $report.Artifacts){if((Get-FileHash -LiteralPath (Join-Path $output $artifact.Name) -Algorithm SHA256).Hash.ToLowerInvariant() -cne $artifact.Sha256){throw 'Saved recovery receipt changed after write.'}}
$report.OriginalDescriptorBytesMatch=$report.After.DescriptorBase64 -ceq $plan.BeforeAddition.DescriptorBase64
$report.Artifacts+=Write-WelaWecUpdateArtifact $output 'confirmed.json' (Get-WelaRegistryRecoveryKey ([pscustomobject]@{Kind='WelaRegistrySaclRecoveryConfirmation';State='Confirmed';PlanHash=$PlanHash;Before=$plan.Expected;After=$report.After;RemoveAce=$plan.AddedAce;RecordedUtc=[DateTime]::UtcNow.ToString('o')}))
$report.Status='AddedAceRemoved';$report.ExitCode=0
}
}catch{$report.Status=if($report.WriteAttempted){'WriteAttemptedUnverified'}else{'Refused'};$report.Diagnostic=$_.Exception.Message}
finally{if($target){try{$target.Dispose()}catch{$report.Status='WriteAttemptedUnverified';$report.ExitCode=1;$report.Diagnostic+=' Native cleanup failed: '+$_.Exception.Message}}}
$null=Write-WelaWecUpdateArtifact $output 'manifest.json' (Get-WelaRegistryRecoveryKey $report)
$report
}
+206
View File
@@ -0,0 +1,206 @@
// Empty-key registry recovery: remove one proven explicit selected-root audit ACE.
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Runtime.InteropServices;
using System.Security.AccessControl;
using System.Security.Principal;
using System.Text;
namespace Wela.RegistrySaclRecovery {
public sealed class Ace { public string Binary; public int Type,Flags,Mask; public string Sid; public bool Ordinary; }
public sealed class Snapshot {
public string Path,Kind,Identity; public bool IsDirectory;
public string DescriptorBase64,Owner,Group,DaclBase64; public int ControlFlags,SecurityInformation;
public string DescriptorScope; public Ace[] Aces;
}
public static class Descriptor {
public const string SourceSha256="__WELA_REGISTRY_SACL_RECOVERY_SOURCE_SHA256__";
public static string Bytes(GenericAcl value) { if(value==null)return null;byte[] b=new byte[value.BinaryLength];value.GetBinaryForm(b,0);return Convert.ToBase64String(b); }
public static string Bytes(GenericAce value) { byte[] b=new byte[value.BinaryLength];value.GetBinaryForm(b,0);return Convert.ToBase64String(b); }
static string Sid(SecurityIdentifier value) {return value==null?null:value.Value;}
public static RawSecurityDescriptor Parse(string value) {
byte[] b=Convert.FromBase64String(value);
if(b.Length<20||b.Length>1048576||Convert.ToBase64String(b)!=value)throw new InvalidOperationException("Invalid or noncanonical descriptor bytes.");
RawSecurityDescriptor sd=new RawSecurityDescriptor(b,0);
return sd;
}
static Dictionary<string,int> Counts(RawAcl acl) {
Dictionary<string,int> counts=new Dictionary<string,int>(StringComparer.Ordinal);
if(acl!=null)foreach(GenericAce ace in acl){string b=Bytes(ace);if(!counts.ContainsKey(b))counts[b]=0;counts[b]++;}
return counts;
}
static void Outside(RawSecurityDescriptor before,RawSecurityDescriptor after,bool allowPresence) {
int mask=allowPresence?~16:~0;
if(Sid(before.Owner)!=Sid(after.Owner)||Sid(before.Group)!=Sid(after.Group)||Bytes(before.DiscretionaryAcl)!=Bytes(after.DiscretionaryAcl)||before.ResourceManagerControl!=after.ResourceManagerControl||(((int)before.ControlFlags)&mask)!=(((int)after.ControlFlags)&mask))throw new InvalidOperationException("Owner, group, DACL or preserved control/header fields differ.");
}
public static string AddedAce(string beforeBytes,string afterBytes,string sid,int mask,int flags) {
if((sid!="S-1-1-0"&&sid!="S-1-5-11")||mask<=0||(flags!=64&&flags!=128&&flags!=192&&flags!=66&&flags!=130&&flags!=194))throw new InvalidOperationException("Only an explicit ordinary selected-root audit ACE is supported.");
RawSecurityDescriptor before=Parse(beforeBytes),after=Parse(afterBytes);Outside(before,after,true);
if(after.SystemAcl==null||after.SystemAcl.Revision!=(before.SystemAcl==null?2:before.SystemAcl.Revision))throw new InvalidOperationException("SACL revision changed during the claimed addition.");
if(before.SystemAcl!=null)foreach(GenericAce entry in before.SystemAcl){CommonAce common=entry as CommonAce;if(common!=null&&!common.IsCallback&&common.AceType==AceType.SystemAudit&&common.SecurityIdentifier.Value==sid&&(int)common.AceFlags==flags&&(common.AccessMask&mask)==mask)throw new InvalidOperationException("Original descriptor already covered the requested audit ACE.");}
string added=Bytes(new CommonAce((AceFlags)flags,AceQualifier.SystemAudit,mask,new SecurityIdentifier(sid),false,null));
Dictionary<string,int> remaining=Counts(after.SystemAcl);
if(!remaining.ContainsKey(added)||remaining[added]!=1)throw new InvalidOperationException("Expected exactly one new matching audit ACE.");
remaining[added]--;
if(before.SystemAcl!=null)foreach(GenericAce entry in before.SystemAcl){string b=Bytes(entry);if(!remaining.ContainsKey(b)||remaining[b]<1)throw new InvalidOperationException("An original ACE was changed or removed.");remaining[b]--;}
foreach(int count in remaining.Values)if(count!=0)throw new InvalidOperationException("The completed operation changed more than one audit ACE.");
int oldCount=before.SystemAcl==null?0:before.SystemAcl.Count;for(int i=0;i<oldCount;i++)if(Bytes(before.SystemAcl[i])!=Bytes(after.SystemAcl[i]))throw new InvalidOperationException("Original ACE ordering changed.");if(Bytes(after.SystemAcl[oldCount])!=added)throw new InvalidOperationException("The selected audit ACE was not the single append.");
return added;
}
public static void Removed(string beforeBytes,string afterBytes,string added) {
RawSecurityDescriptor before=Parse(beforeBytes),after=Parse(afterBytes);Outside(before,after,false);
if(before.SystemAcl==null)throw new InvalidOperationException("Original SACL is absent.");
if(after.SystemAcl==null){if(before.SystemAcl.Count!=1)throw new InvalidOperationException("A null SACL would lose unrelated audit ACEs.");}
else if(before.SystemAcl.Revision!=after.SystemAcl.Revision)throw new InvalidOperationException("SACL revision changed during removal: "+before.SystemAcl.Revision+" to "+after.SystemAcl.Revision+" (after count "+after.SystemAcl.Count+").");
Dictionary<string,int> expected=Counts(before.SystemAcl),actual=Counts(after.SystemAcl);
if(!expected.ContainsKey(added)||expected[added]!=1)throw new InvalidOperationException("The selected audit ACE is no longer unique.");
expected[added]--;
foreach(KeyValuePair<string,int> entry in expected){int count=actual.ContainsKey(entry.Key)?actual[entry.Key]:0;if(count!=entry.Value)throw new InvalidOperationException("Unrelated audit ACEs changed during removal.");actual.Remove(entry.Key);}
if(actual.Count!=0)throw new InvalidOperationException("Unexpected ACE appeared during removal.");
List<string> ordered=new List<string>();foreach(GenericAce entry in before.SystemAcl)if(Bytes(entry)!=added)ordered.Add(Bytes(entry));if(after.SystemAcl!=null){for(int i=0;i<ordered.Count;i++)if(Bytes(after.SystemAcl[i])!=ordered[i])throw new InvalidOperationException("Unrelated audit ACE order changed during removal.");}
}
public static string SaclRepresentation(string value) {
RawSecurityDescriptor sd=Parse(value);bool present=(sd.ControlFlags&ControlFlags.SystemAclPresent)!=0;
if(!present)return "Absent";
if(sd.SystemAcl==null)return "PresentNull";
return (sd.SystemAcl.Count==0?"PresentEmpty":"PresentWithAces")+";Revision="+sd.SystemAcl.Revision;
}
public static Snapshot Observe(string path,string identity,byte[] bytes) {
string encoded=Convert.ToBase64String(bytes);RawSecurityDescriptor sd=Parse(encoded);List<Ace> entries=new List<Ace>();
if(sd.SystemAcl!=null)foreach(GenericAce ace in sd.SystemAcl){CommonAce common=ace as CommonAce;bool ordinary=common!=null&&!common.IsCallback&&common.AceType==AceType.SystemAudit;entries.Add(new Ace {Binary=Bytes(ace),Type=(int)ace.AceType,Flags=(int)ace.AceFlags,Mask=ordinary?common.AccessMask:0,Sid=ordinary?common.SecurityIdentifier.Value:null,Ordinary=ordinary});}
return new Snapshot {Path=path,Kind="Registry",Identity=identity,IsDirectory=false,DescriptorBase64=encoded,Owner=Sid(sd.Owner),Group=Sid(sd.Group),DaclBase64=Bytes(sd.DiscretionaryAcl),ControlFlags=(int)sd.ControlFlags,SecurityInformation=511,DescriptorScope="WinSDK-defined sections 0x1ff; future sections unobserved",Aces=entries.ToArray()};
}
}
sealed class Privilege : IDisposable {
[StructLayout(LayoutKind.Sequential)] struct Luid {public uint Low;public int High;}
[StructLayout(LayoutKind.Sequential)] struct TokenPrivileges {public uint Count;public Luid Luid;public uint Attributes;}
[DllImport("kernel32.dll")] static extern IntPtr GetCurrentProcess();
[DllImport("kernel32.dll")] static extern IntPtr GetCurrentThread();
[DllImport("kernel32.dll",SetLastError=true)] static extern bool CloseHandle(IntPtr value);
[DllImport("advapi32.dll",SetLastError=true)] static extern bool OpenProcessToken(IntPtr process,uint access,out IntPtr token);
[DllImport("advapi32.dll",SetLastError=true)] static extern bool OpenThreadToken(IntPtr thread,uint access,bool self,out IntPtr token);
[DllImport("advapi32.dll",CharSet=CharSet.Unicode,SetLastError=true)] static extern bool LookupPrivilegeValue(string system,string name,out Luid luid);
[DllImport("advapi32.dll",SetLastError=true)] static extern bool AdjustTokenPrivileges(IntPtr token,bool disable,ref TokenPrivileges value,uint size,out TokenPrivileges previous,out uint required);
IntPtr token;TokenPrivileges previous;
public Privilege(){IntPtr thread;
if(OpenThreadToken(GetCurrentThread(),8,true,out thread)){CloseHandle(thread);throw new InvalidOperationException("Impersonated recovery is unsupported.");}
int error=Marshal.GetLastWin32Error();if(error!=1008)throw new Win32Exception(error);
if(!OpenProcessToken(GetCurrentProcess(),0x28,out token))throw new Win32Exception(Marshal.GetLastWin32Error());
try{Luid luid;if(!LookupPrivilegeValue(null,"SeSecurityPrivilege",out luid))throw new Win32Exception(Marshal.GetLastWin32Error());TokenPrivileges request=new TokenPrivileges {Count=1,Luid=luid,Attributes=2};uint required;bool ok=AdjustTokenPrivileges(token,false,ref request,(uint)Marshal.SizeOf(typeof(TokenPrivileges)),out previous,out required);error=Marshal.GetLastWin32Error();if(!ok||error!=0)throw new Win32Exception(error,"SeSecurityPrivilege is unavailable.");}
catch{CloseHandle(token);token=IntPtr.Zero;throw;}
}
public void Dispose(){if(token==IntPtr.Zero)return;try{TokenPrivileges ignored;uint required;bool ok=AdjustTokenPrivileges(token,false,ref previous,(uint)Marshal.SizeOf(typeof(TokenPrivileges)),out ignored,out required);int error=Marshal.GetLastWin32Error();if(!ok||error!=0)throw new Win32Exception(error,"SeSecurityPrivilege restoration failed.");}finally{CloseHandle(token);token=IntPtr.Zero;}}
}
public sealed class Target : IDisposable {
[DllImport("kernel32.dll")] static extern IntPtr LocalFree(IntPtr value);
[DllImport("advapi32.dll",CharSet=CharSet.Unicode,ExactSpelling=true)] static extern int RegOpenKeyExW(IntPtr root,string name,uint options,uint access,out IntPtr key);
[DllImport("advapi32.dll",CharSet=CharSet.Unicode,ExactSpelling=true)] static extern int RegQueryValueExW(IntPtr key,string name,IntPtr reserved,out uint type,IntPtr data,ref uint size);
[DllImport("advapi32.dll",CharSet=CharSet.Unicode,ExactSpelling=true)] static extern int RegQueryInfoKeyW(IntPtr key,IntPtr cls,IntPtr clsSize,IntPtr reserved,IntPtr subKeys,IntPtr maxSubKey,IntPtr maxClass,IntPtr values,IntPtr maxValueName,IntPtr maxValue,IntPtr securitySize,out long written);
[DllImport("advapi32.dll",CharSet=CharSet.Unicode,ExactSpelling=true)] static extern int RegEnumKeyExW(IntPtr key,uint index,StringBuilder name,ref uint length,IntPtr reserved,IntPtr cls,IntPtr clsLength,IntPtr written);
[DllImport("advapi32.dll")] static extern int RegCloseKey(IntPtr key);
[DllImport("ntdll.dll")] static extern int NtQueryKey(IntPtr key,int cls,IntPtr information,uint length,out uint resultLength);
[DllImport("advapi32.dll")] static extern uint GetSecurityInfo(IntPtr handle,uint kind,uint flags,out IntPtr owner,out IntPtr group,out IntPtr dacl,out IntPtr sacl,out IntPtr descriptor);
[DllImport("advapi32.dll")] static extern uint GetSecurityDescriptorLength(IntPtr descriptor);
[DllImport("advapi32.dll")] static extern uint SetSecurityInfo(IntPtr handle,uint kind,uint flags,IntPtr owner,IntPtr group,IntPtr dacl,IntPtr sacl);
readonly string path,nativePath;readonly List<IntPtr> keys=new List<IntPtr>();IntPtr handle;Privilege privilege;
public bool WriteAttempted {get;private set;}public Snapshot AfterObservation {get;private set;}
public Target(string path){
this.path=path;
if(String.IsNullOrEmpty(path)||path.IndexOfAny(new char[]{'/','*','?','%','\0'})>=0)throw new InvalidOperationException("Exact local registry path required.");
string[] parts=path.Split('\\');IntPtr root;
if(parts[0]=="HKEY_LOCAL_MACHINE"){root=new IntPtr(unchecked((int)0x80000002));nativePath="\\REGISTRY\\MACHINE";}
else if(parts[0]=="HKEY_USERS"){root=new IntPtr(unchecked((int)0x80000003));nativePath="\\REGISTRY\\USER";}
else throw new InvalidOperationException("Only selected HKLM/HKU keys are supported.");
if(parts.Length<2)throw new InvalidOperationException("Hive roots cannot be recovered.");
for(int i=1;i<parts.Length;i++){if(parts[i].Length==0||parts[i]=="."||parts[i]=="..")throw new InvalidOperationException("Ambiguous registry path.");nativePath+="\\"+parts[i];}
try{
privilege=new Privilege();IntPtr current=root;
for(int i=1;i<parts.Length;i++){
IntPtr opened;int error=RegOpenKeyExW(current,parts[i],8,0x01020101U|(i==parts.Length-1?8U:0U),out opened);
if(error!=0)throw new Win32Exception(error,"Selected registry component open failed.");keys.Add(opened);current=opened;
uint type,size=0;error=RegQueryValueExW(current,"SymbolicLinkValue",IntPtr.Zero,out type,IntPtr.Zero,ref size);
if(error==0&&type==6)throw new InvalidOperationException("Registry symbolic-link component refused.");if(error!=0&&error!=2)throw new Win32Exception(error,"Registry link state is unreadable.");
}
handle=current;Check();AssertEmpty();
}catch{Dispose();throw;}
}
string Check(){
if(handle==IntPtr.Zero)throw new ObjectDisposedException("Target");uint required;int status=NtQueryKey(handle,3,IntPtr.Zero,0,out required);
if((status!=unchecked((int)0xC0000023)&&status!=unchecked((int)0x80000005))||required<6||required>65536)throw new InvalidOperationException("Native registry name query is unavailable.");
IntPtr buffer=Marshal.AllocHGlobal((int)required);
try{uint actual;status=NtQueryKey(handle,3,buffer,required,out actual);if(status!=0||actual>required)throw new InvalidOperationException("Native registry name query failed.");int size=Marshal.ReadInt32(buffer);if(size<2||size%2!=0||size>required-4)throw new InvalidOperationException("Native registry name is malformed.");string name=Marshal.PtrToStringUni(IntPtr.Add(buffer,4),size/2);if(!String.Equals(name,nativePath,StringComparison.OrdinalIgnoreCase))throw new InvalidOperationException("Held registry name differs from the selected path.");}finally{Marshal.FreeHGlobal(buffer);}
long written;int error=RegQueryInfoKeyW(handle,IntPtr.Zero,IntPtr.Zero,IntPtr.Zero,IntPtr.Zero,IntPtr.Zero,IntPtr.Zero,IntPtr.Zero,IntPtr.Zero,IntPtr.Zero,IntPtr.Zero,out written);if(error!=0)throw new Win32Exception(error,"Registry last-write observation failed.");return path+":"+written;
}
public void AssertEmpty(){if(handle==IntPtr.Zero)throw new ObjectDisposedException("Target");StringBuilder name=new StringBuilder(256);uint size=256;int error=RegEnumKeyExW(handle,0,name,ref size,IntPtr.Zero,IntPtr.Zero,IntPtr.Zero,IntPtr.Zero);if(error==0||error==234)throw new InvalidOperationException("Recovery requires an observed empty registry descendant inventory.");if(error!=259)throw new Win32Exception(error,"Registry child enumeration is unknown.");}
public Snapshot Read(){
string identity=Check();AssertEmpty();IntPtr owner,group,dacl,sacl,descriptor;uint error=GetSecurityInfo(handle,4,511,out owner,out group,out dacl,out sacl,out descriptor);if(error!=0)throw new Win32Exception((int)error,"Full SDK-defined registry descriptor read failed.");byte[] bytes;
try{uint size=GetSecurityDescriptorLength(descriptor);if(size<20||size>1048576)throw new InvalidOperationException("Invalid native descriptor size.");bytes=new byte[size];Marshal.Copy(descriptor,bytes,0,(int)size);}finally{LocalFree(descriptor);}
AssertEmpty();if(Check()!=identity)throw new InvalidOperationException("Registry last-write identity changed during observation.");return Descriptor.Observe(path,identity,bytes);
}
public Snapshot Remove(string expectedIdentity,string expectedDescriptor,string added){
Snapshot before=Read();if(before.Identity!=expectedIdentity||before.DescriptorBase64!=expectedDescriptor)throw new InvalidOperationException("Reviewed registry identity or descriptor changed before removal.");
RawSecurityDescriptor sd=Descriptor.Parse(before.DescriptorBase64);int index=-1;
if(sd.SystemAcl!=null)for(int i=0;i<sd.SystemAcl.Count;i++)if(Descriptor.Bytes(sd.SystemAcl[i])==added){if(index!=-1)throw new InvalidOperationException("Audit ACE is not unique.");index=i;}
if(index<0)throw new InvalidOperationException("Audit ACE is absent.");CommonAce ace=sd.SystemAcl[index] as CommonAce;
if(ace==null||ace.IsCallback||ace.AceType!=AceType.SystemAudit||((int)ace.AceFlags!=64&&(int)ace.AceFlags!=128&&(int)ace.AceFlags!=192&&(int)ace.AceFlags!=66&&(int)ace.AceFlags!=130&&(int)ace.AceFlags!=194))throw new InvalidOperationException("Only the proven explicit selected-root audit ACE can be removed.");
sd.SystemAcl.RemoveAce(index);byte[] bytes=new byte[sd.SystemAcl.BinaryLength];sd.SystemAcl.GetBinaryForm(bytes,0);IntPtr buffer=Marshal.AllocHGlobal(bytes.Length);
try{
Marshal.Copy(bytes,0,buffer,bytes.Length);Snapshot last=Read();if(last.Identity!=expectedIdentity||last.DescriptorBase64!=expectedDescriptor)throw new InvalidOperationException("Registry changed immediately before removal.");
WriteAttempted=true;uint error=SetSecurityInfo(handle,4,8,IntPtr.Zero,IntPtr.Zero,IntPtr.Zero,buffer);if(error!=0)throw new Win32Exception((int)error,"SACL-only registry removal failed.");
}finally{Marshal.FreeHGlobal(buffer);}
Snapshot after=Read();AfterObservation=after;Descriptor.Removed(before.DescriptorBase64,after.DescriptorBase64,added);return after;
}
public void Dispose(){try{for(int i=keys.Count-1;i>=0;i--)RegCloseKey(keys[i]);keys.Clear();handle=IntPtr.Zero;}finally{if(privilege!=null){privilege.Dispose();privilege=null;}}}
}
public sealed class Group { public string Sid; public uint Attributes; }
public sealed class TokenPrivilege { public string Luid; public uint Attributes; }
public sealed class Token {
public string Sid, Name, AuthenticationId, AuthenticationType, ImpersonationLevel, TokenSource;
public Group[] Groups; public TokenPrivilege[] Privileges;
}
public static class TokenReader {
[DllImport("kernel32.dll",ExactSpelling=true)] static extern void GetSystemTimePreciseAsFileTime(out long value);
public static DateTime UtcNow() {long value;GetSystemTimePreciseAsFileTime(out value);return DateTime.FromFileTimeUtc(value);}
[StructLayout(LayoutKind.Sequential)] struct Luid {public uint Low; public int High;}
[StructLayout(LayoutKind.Sequential)] struct Statistics {public Luid TokenId,AuthenticationId;public long Expiration;public int Type,Level;public uint Charged,Available,Groups,Privileges;public Luid Modified;}
[StructLayout(LayoutKind.Sequential)] struct SidAndAttributes {public IntPtr Sid;public uint Attributes;}
[StructLayout(LayoutKind.Sequential)] struct TokenGroups {public uint Count;public SidAndAttributes First;}
[StructLayout(LayoutKind.Sequential)] struct LuidAndAttributes {public Luid Luid;public uint Attributes;}
[DllImport("kernel32.dll")] static extern IntPtr GetCurrentProcess();
[DllImport("kernel32.dll")] static extern IntPtr GetCurrentThread();
[DllImport("kernel32.dll",SetLastError=true)] static extern bool CloseHandle(IntPtr h);
[DllImport("advapi32.dll",SetLastError=true)] static extern bool OpenProcessToken(IntPtr p,uint access,out IntPtr t);
[DllImport("advapi32.dll",SetLastError=true)] static extern bool OpenThreadToken(IntPtr p,uint access,bool self,out IntPtr t);
[DllImport("advapi32.dll",SetLastError=true)] static extern bool GetTokenInformation(IntPtr t,int cls,IntPtr data,int length,out int needed);
static string Hex(Luid id) {return "0x"+(((ulong)(uint)id.High<<32)|id.Low).ToString("x");}
static IntPtr Read(IntPtr token,int cls,out int length) {
GetTokenInformation(token,cls,IntPtr.Zero,0,out length);
if(Marshal.GetLastWin32Error()!=122||length<4||length>65536)throw new InvalidOperationException("Unknown or oversized token information.");
IntPtr data=Marshal.AllocHGlobal(length);
if(!GetTokenInformation(token,cls,data,length,out length)){int error=Marshal.GetLastWin32Error();Marshal.FreeHGlobal(data);throw new Win32Exception(error);}
return data;
}
static Token ReadToken(IntPtr token,string source) {
Token result=new Token();result.TokenSource=source;using(WindowsIdentity identity=new WindowsIdentity(token)){result.Sid=identity.User.Value;result.Name=identity.Name;result.AuthenticationType=identity.AuthenticationType;result.ImpersonationLevel=identity.ImpersonationLevel.ToString();}
int length;IntPtr p=Read(token,10,out length);
try {if(length<Marshal.SizeOf(typeof(Statistics)))throw new InvalidOperationException("Truncated token statistics.");result.AuthenticationId=Hex(((Statistics)Marshal.PtrToStructure(p,typeof(Statistics))).AuthenticationId);}finally{Marshal.FreeHGlobal(p);}
p=Read(token,2,out length);
try {int count=Marshal.ReadInt32(p),offset=(int)Marshal.OffsetOf(typeof(TokenGroups),"First"),size=Marshal.SizeOf(typeof(SidAndAttributes));if(count<0||count>4096||offset+(long)count*size>length)throw new InvalidOperationException("Invalid token groups.");List<Group> groups=new List<Group>();for(int i=0;i<count;i++){SidAndAttributes g=(SidAndAttributes)Marshal.PtrToStructure(IntPtr.Add(p,offset+i*size),typeof(SidAndAttributes));groups.Add(new Group{Sid=new SecurityIdentifier(g.Sid).Value,Attributes=g.Attributes});}groups.Sort((a,b)=>String.CompareOrdinal(a.Sid,b.Sid));result.Groups=groups.ToArray();}finally{Marshal.FreeHGlobal(p);}
p=Read(token,3,out length);
try {int count=Marshal.ReadInt32(p),size=Marshal.SizeOf(typeof(LuidAndAttributes));if(count<0||count>4096||4+(long)count*size>length)throw new InvalidOperationException("Invalid token privileges.");List<TokenPrivilege> privileges=new List<TokenPrivilege>();for(int i=0;i<count;i++){LuidAndAttributes v=(LuidAndAttributes)Marshal.PtrToStructure(IntPtr.Add(p,4+i*size),typeof(LuidAndAttributes));privileges.Add(new TokenPrivilege{Luid=Hex(v.Luid),Attributes=v.Attributes});}privileges.Sort((a,b)=>String.CompareOrdinal(a.Luid,b.Luid));result.Privileges=privileges.ToArray();}finally{Marshal.FreeHGlobal(p);}
return result;
}
[DllImport("advapi32.dll")] static extern bool IsTokenRestricted(IntPtr token);
public static Token Snapshot() {
IntPtr thread=IntPtr.Zero,process=IntPtr.Zero;
if(!OpenThreadToken(GetCurrentThread(),8,true,out thread)){int error=Marshal.GetLastWin32Error();if(error!=1008)throw new Win32Exception(error);}
try {
if(!OpenProcessToken(GetCurrentProcess(),8,out process))throw new Win32Exception(Marshal.GetLastWin32Error());
if(IsTokenRestricted(process)||(thread!=IntPtr.Zero&&IsTokenRestricted(thread)))throw new InvalidOperationException("Restricted tokens are unsupported.");
Token primary=ReadToken(process,"Process");
if(thread!=IntPtr.Zero)throw new InvalidOperationException("Impersonated recovery is unsupported.");
return primary;
} finally {if(process!=IntPtr.Zero)CloseHandle(process);if(thread!=IntPtr.Zero)CloseHandle(thread);}
}
}
}
+14
View File
@@ -0,0 +1,14 @@
$ErrorActionPreference='Stop';$repo=Split-Path $PSScriptRoot -Parent;$engine=(Get-Process -Id $PID).Path
$cases=@(
@{Args=@('registry-sacl-recovery','-Help');Code=0;Pattern='One proven registry-root audit ACE'},
@{Args=@('registry-sacl-recovery','unexpected','-Help');Code=1;Pattern='arguments|dedicated options'},
@{Args=@('registry-sacl-recovery','-WhatIf','-Help');Code=1;Pattern='arguments|dedicated options'},
@{Args=@('registry-sacl-recovery','-DryRun','-Help');Code=1;Pattern='dedicated options'},
@{Args=@('registry-sacl-recovery','-Auto','-Help');Code=1;Pattern='dedicated options'},
@{Args=@('help','-RegistryRecoveryAction','Restore');Code=1;Pattern='require registry-sacl-recovery'},
@{Args=@('registry-sacl-recovery');Code=1;Pattern='four original evidence paths'},
@{Args=@('registry-sacl-recovery','-RegistryRecoveryAction','Restore');Code=1;Pattern='reviewed plan path/hash'},
@{Args=@('registry-sacl-recovery','-RegistryRecoveryAllowAuditReduction');Code=1;Pattern='four original evidence paths'}
)
foreach($case in $cases){$old=$ErrorActionPreference;try{$ErrorActionPreference='Continue';$output=@(& $engine -NoLogo -NoProfile -NonInteractive -File (Join-Path $repo 'WELA.ps1') @($case.Args) 2>&1);$code=$LASTEXITCODE}finally{$ErrorActionPreference=$old};if($code -ne $case.Code -or ($output -join "`n") -notmatch $case.Pattern){throw "CLI refusal failure: $($case.Args -join ' ') => $code / $($output -join ' ')"}}
Write-Host "Passed $($cases.Count) public registry recovery CLI assertions.";$global:LASTEXITCODE=0
@@ -0,0 +1,114 @@
# Mutating test fixture only: public WELA never loads hives or prepares audit policy.
param([switch]$AllowDisposableHiveWrite)
$ErrorActionPreference='Stop'
if(-not $AllowDisposableHiveWrite -or $env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted' -or [Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT -or -not [Environment]::Is64BitProcess){throw 'Explicit disposable GitHub-hosted native Windows fixture only.'}
$script:ScriptRoot=Split-Path $PSScriptRoot -Parent
Import-Module (Join-Path $script:ScriptRoot 'modules/AuditProfiles.psm1') -ErrorAction Stop
foreach($name in @('Configuration','WefArrival','WmiProbe','ChannelRead','SelectedSaclConfiguration','RegistrySaclRecovery')){. (Join-Path $script:ScriptRoot ('scripts/'+$name+'.ps1'))}
. (Join-Path $PSScriptRoot 'RegistrySaclLifecycleEvidence.ps1')
Initialize-WelaWmiProbeNative
Add-Type -Path (Join-Path $PSScriptRoot 'RegistrySaclFixtureNative.cs') -ErrorAction Stop
$root=New-WelaArrivalOutput (Join-Path $env:RUNNER_TEMP ('wela-registry-recovery-'+[guid]::NewGuid().ToString('N'))) $script:ScriptRoot
$files=Join-Path $root 'owned-hive-files';$null=New-Item -ItemType Directory $files
function Save([string]$Name,$Value){[IO.File]::WriteAllText((Join-Path $root $Name),($Value|ConvertTo-Json -Depth 28),[Text.UTF8Encoding]::new($false))}
function Read-Receipt([string]$Name){ConvertFrom-WelaArrivalJson ([IO.File]::ReadAllText((Join-Path $root $Name)))}
function Hives {@([Microsoft.Win32.Registry]::Users.GetSubKeyNames()|Sort-Object)}
function Key($Value){ConvertTo-Json -InputObject $Value -Depth 24 -Compress}
$script:assertions=0
function Assert($Condition,[string]$Message){if(-not $Condition){throw $Message};$script:assertions++}
Add-Type -TypeDefinition @'
using System;using System.IO;using System.Text;using System.Threading.Tasks;
public static class WelaRegistryRecoveryFixturePipe {
public static async Task<string> Read(TextReader reader){var text=new StringBuilder();var buffer=new char[1024];while(true){int n=await reader.ReadAsync(buffer,0,buffer.Length).ConfigureAwait(false);if(n==0)return text.ToString();if(n>1048576-text.Length)throw new InvalidDataException("Fixture output exceeds one Mi character bound.");text.Append(buffer,0,n);}}
}
'@
function Public([string]$Name,[string[]]$Arguments,[int]$Expected=0){
$all=@('-NoLogo','-NoProfile','-NonInteractive','-File',(Join-Path $script:ScriptRoot 'WELA.ps1'))+$Arguments
foreach($a in $all){if($a.Contains('"') -or $a.EndsWith('\') -or $a -match '[\x00-\x1f]'){throw 'Ambiguous fixture argument.'}}
$info=[Diagnostics.ProcessStartInfo]::new();$info.FileName=$engine;$info.Arguments=(@($all|ForEach-Object {'"'+$_+'"'}) -join ' ');$info.UseShellExecute=$false;$info.CreateNoWindow=$true;$info.RedirectStandardOutput=$true;$info.RedirectStandardError=$true
$process=[Diagnostics.Process]::new();$process.StartInfo=$info;$started=$false
try{
if(-not $process.Start()){throw 'Public process did not start.'};$started=$true
$stdout=[WelaRegistryRecoveryFixturePipe]::Read($process.StandardOutput);$stderr=[WelaRegistryRecoveryFixturePipe]::Read($process.StandardError)
if(-not $process.WaitForExit(180000)){throw 'Public command exceeded three minutes.'}
if(-not [Threading.Tasks.Task]::WaitAll([Threading.Tasks.Task[]]@($stdout,$stderr),5000)){throw 'Public output drain timed out.'}
$text=$stdout.Result+"`n"+$stderr.Result;Save ($Name+'-output.json') $text
Assert ($process.ExitCode -eq $Expected) ("Public $Name exited $($process.ExitCode), expected $Expected : "+$text)
}finally{
if($started){$exited=$false;try{$exited=$process.HasExited}catch{$script:cleanupErrors+=$_.Exception.Message};if(-not $exited){try{$process.Kill()}catch{$script:cleanupErrors+=$_.Exception.Message};try{$exited=$process.WaitForExit(5000)}catch{$script:cleanupErrors+=$_.Exception.Message}};if(-not $exited){$script:cleanupErrors+='Owned public process termination unconfirmed.'}}
$process.Dispose()
}
}
$engine=(Get-Process -Id $PID).Path;$guid='0CCE921E-69AE-11D9-BED3-505054503030'
$precedencePath='HKLM:\SYSTEM\CurrentControlSet\Control\Lsa';$precedenceName='SCENoApplyLegacyAuditPolicy'
$beforeHives=Hives;$beforeToken=[Wela.WmiProbe.Native]::Snapshot();$beforeMasks=Get-WelaEffectiveAuditPolicy;$beforePrecedence=Get-WelaRegistryState $precedencePath $precedenceName
Save 'before-hives.json' $beforeHives;Save 'before-token.json' $beforeToken;Save 'before-masks.json' $beforeMasks;Save 'before-precedence.json' $beforePrecedence
$hive=[Wela.RegistrySaclFixture.Hive]::new([guid]::NewGuid().ToString('N'),(Join-Path $files 'owned.dat'));$failure=$null;$cleanupErrors=@();$policyTouched=$false
try {
Assert ($beforeMasks.Count -eq 59) 'All 59 original audit masks observed.'
$hive.Prepare();$hive.CreateRunOnce();$hive.AssertOwned();$hive.AssertValues($false)
$providerPath='Registry::HKEY_USERS\'+$hive.Sid+'\Software\Microsoft\Windows\CurrentVersion\RunOnce'
Save 'mounted.json' ([pscustomobject]@{Sid=$hive.Sid;File=$hive.FilePath;Seed=$hive.SeedPath;Target=$providerPath})
Assert ((Key (Hives)) -ceq (Key (@($beforeHives)+$hive.Sid|Sort-Object))) 'Only fixture-owned hive was mounted.'
$policyTouched=$true;Set-ItemProperty -LiteralPath $precedencePath -Name $precedenceName -Type DWord -Value 1;Set-WelaEffectiveAuditPolicy -Guid $guid -Mask 3 -Mode exact
$preparedMasks=Key ((Get-WelaEffectiveAuditPolicy).GetEnumerator()|Sort-Object Key);$preparedPrecedence=Key (Get-WelaRegistryState $precedencePath $precedenceName)
Public 'catalog' @('targeted-sacl','-TargetSaclProfile','asd-native-2021-10','-IncludeOptional','-ResultsPath',(Join-Path $root 'catalog.json'))
$catalog=Read-Receipt 'catalog.json';$selectedRows=@($catalog.Catalog|Where-Object {$_.Definition.UserSid -ceq $hive.Sid -and $_.Definition.Path -ieq $providerPath})
Assert ($selectedRows.Count -eq 1 -and $selectedRows[0].Definition.Kind -ceq 'Registry') 'Exactly one real catalog target in owned HKU hive.'
$selected=$selectedRows[0];Save 'selected.json' $selected
Initialize-WelaSelectedSaclNative;$privilege=[Wela.SelectedSacl.Privilege]::new();$target=$null
try{$target=[Wela.SelectedSacl.Target]::new('Registry',(Resolve-WelaSelectedSaclNativePath $selected.Definition));$before=$target.Read();$seeded=$target.Add($before.Identity,$before.DescriptorBase64,'S-1-5-18',1,64)}finally{if($target){$target.Dispose()};$privilege.Dispose()}
Save 'before-public.json' $seeded
$originalPlan=Join-Path $root 'original-plan.json';$backup=Join-Path $root 'original-journal';$originalResults=Join-Path $root 'original-results.json'
$selection=@('targeted-sacl','-TargetSaclProfile','asd-native-2021-10','-TargetSaclId',$selected.Id,'-IncludeOptional','-TargetSaclIncludeChildren')
Public 'original-plan' ($selection+@('-TargetSaclAction','Plan','-ResultsPath',$originalPlan))
Public 'original-configure' ($selection+@('-TargetSaclAction','Configure','-TargetSaclPlanPath',$originalPlan,'-BackupPath',$backup,'-ResultsPath',$originalResults,'-Auto'))
$result=Read-Receipt 'original-results.json';Assert ($result.Results[0].Status -is [string] -and $result.Results[0].Status -ceq 'Applied') 'Recovery starts from actual completed public Configure.'
$applied=Get-WelaSelectedSaclSnapshot $selected.Definition;Save 'applied-native.json' $applied;$hive.AssertValues($false)
$review=Join-Path $root 'review';$pending=Join-Path $backup ($selected.Id+'.pending.json');$confirmed=Join-Path $backup ($selected.Id+'.confirmed.json')
$reviewArgs=@('registry-sacl-recovery','-RegistryRecoveryOriginalPlanPath',$originalPlan,'-RegistryRecoveryPendingPath',$pending,'-RegistryRecoveryConfirmedPath',$confirmed,'-RegistryRecoveryOriginalResultsPath',$originalResults)
Public 'recovery-plan' ($reviewArgs+@('-RegistryRecoveryOutputPath',$review))
$manifest=Read-Receipt 'review/manifest.json';Assert ($manifest.Status -ceq 'ReviewRequired' -and -not $manifest.WriteAttempted -and $manifest.PlanHash -ceq (Get-FileHash -LiteralPath (Join-Path $review 'plan.json')).Hash.ToLowerInvariant()) 'Actual recovery Plan binds independently checked exact bytes without writes.'
Assert ((Get-WelaSelectedSaclSnapshotKey (Get-WelaSelectedSaclSnapshot $selected.Definition)) -ceq (Get-WelaSelectedSaclSnapshotKey $applied)) 'Recovery planning preserves exact native state.'
$restoreArgs=@('registry-sacl-recovery','-RegistryRecoveryAction','Restore','-RegistryRecoveryPlanPath',(Join-Path $review 'plan.json'),'-RegistryRecoveryPlanHash',$manifest.PlanHash)
foreach($missing in @('AuditReduction','Inheritance')){
$out=Join-Path $root ('missing-'+$missing);$consent=if($missing -ceq 'AuditReduction'){'-RegistryRecoveryAllowInheritance'}else{'-RegistryRecoveryAllowAuditReduction'}
Public ('missing-'+$missing) ($restoreArgs+@('-RegistryRecoveryOutputPath',$out,$consent)) 1
$refusal=Read-Receipt ('missing-'+$missing+'/manifest.json');Assert ($refusal.Status -ceq 'Refused' -and -not $refusal.WriteAttempted -and -not(Test-Path -LiteralPath (Join-Path $out 'pending.json'))) 'Each explicit reduction/inheritance consent is required before durable intent or removal.'
}
$restoredDir=Join-Path $root 'restored'
Public 'restore' ($restoreArgs+@('-RegistryRecoveryOutputPath',$restoredDir,'-RegistryRecoveryAllowAuditReduction','-RegistryRecoveryAllowInheritance'))
$restored=Read-Receipt 'restored/manifest.json';Save 'restored-native.json' (Get-WelaSelectedSaclSnapshot $selected.Definition)
Assert ($restored.Status -ceq 'AddedAceRemoved' -and $restored.WriteAttempted -and $restored.PolicyChanges -eq 0 -and $restored.ReadyRuleCredit -eq 0) 'Actual public recovery removes one proven ACE with no audit-policy or rule credit.'
$native=Get-WelaSelectedSaclSnapshot $selected.Definition;Initialize-WelaRegistryRecoveryNative
[Wela.RegistrySaclRecovery.Descriptor]::Removed($applied.DescriptorBase64,$native.DescriptorBase64,(Read-Receipt 'review/plan.json').AddedAce)
Assert ($native.Aces.Count -eq $seeded.Aces.Count -and $native.Aces[0].Binary -ceq $seeded.Aces[0].Binary -and $native.Owner -ceq $seeded.Owner -and $native.Group -ceq $seeded.Group -and $native.DaclBase64 -ceq $seeded.DaclBase64) 'Independent native observation retains unrelated audit ACE, owner/group/DACL.'
$hive.AssertValues($false)
foreach($artifact in $restored.Artifacts){Assert ((Get-FileHash -LiteralPath (Join-Path $restoredDir $artifact.Name)).Hash.ToLowerInvariant() -ceq $artifact.Sha256) 'Durable actual recovery artifact hash verified.'}
Assert ((Read-Receipt 'restored/pending.json').State -ceq 'Pending' -and (Read-Receipt 'restored/confirmed.json').State -ceq 'Confirmed') 'Distinct durable intent and verified completion receipts exist.'
$replay=Join-Path $root 'replay';Public 'replay' ($restoreArgs+@('-RegistryRecoveryOutputPath',$replay,'-RegistryRecoveryAllowAuditReduction','-RegistryRecoveryAllowInheritance')) 1
Assert ((Read-Receipt 'replay/manifest.json').Status -ceq 'Refused' -and -not (Read-Receipt 'replay/manifest.json').WriteAttempted) 'Old reviewed restore refuses replay.'
Assert ((Key ((Get-WelaEffectiveAuditPolicy).GetEnumerator()|Sort-Object Key)) -ceq $preparedMasks -and (Key (Get-WelaRegistryState $precedencePath $precedenceName)) -ceq $preparedPrecedence) 'All public operations preserve prepared auditing.'
Assert ((Key ([Wela.WmiProbe.Native]::Snapshot())) -ceq (Key $beforeToken)) 'All native fixture security/backup/restore privilege attributes restored.'
}catch{$failure=$_}finally {
if($policyTouched){
try{Set-WelaEffectiveAuditPolicy -Guid $guid -Mask $beforeMasks[$guid] -Mode exact}catch{$cleanupErrors+='Audit restore: '+$_.Exception.Message}
try{if($beforePrecedence.ValueExists){Set-ItemProperty -LiteralPath $precedencePath -Name $precedenceName -Type $beforePrecedence.Type -Value $beforePrecedence.Value}else{Remove-ItemProperty -LiteralPath $precedencePath -Name $precedenceName -ErrorAction Stop}}catch{$cleanupErrors+='Precedence restore: '+$_.Exception.Message}
}
try{$hive.Dispose()}catch{$cleanupErrors+='Hive unload/seed removal: '+$_.Exception.Message}
$hivesOk=$false;$tokenOk=$false;$masksOk=$false;$precedenceOk=$false
$afterHives=$null;$afterToken=$null;$masks=$null;$afterPrecedence=$null
try{$afterHives=Hives;$hivesOk=(Key $afterHives) -ceq (Key $beforeHives)}catch{$cleanupErrors+='HKU verification: '+$_.Exception.Message}
try{$afterToken=[Wela.WmiProbe.Native]::Snapshot();$tokenOk=(Key $afterToken) -ceq (Key $beforeToken)}catch{$cleanupErrors+='Token verification: '+$_.Exception.Message}
try{$masks=Get-WelaEffectiveAuditPolicy;$masksOk=(Key ($masks.GetEnumerator()|Sort-Object Key)) -ceq (Key ($beforeMasks.GetEnumerator()|Sort-Object Key))}catch{$cleanupErrors+='Audit verification: '+$_.Exception.Message}
try{$afterPrecedence=Get-WelaRegistryState $precedencePath $precedenceName;$precedenceOk=(Key $afterPrecedence) -ceq (Key $beforePrecedence)}catch{$cleanupErrors+='Precedence verification: '+$_.Exception.Message}
if(-not $hive.Loaded -and $hivesOk){try{Remove-Item -LiteralPath $files -Recurse -Force -ErrorAction Stop}catch{$cleanupErrors+='Owned file removal: '+$_.Exception.Message}}
$cleanup=[pscustomobject]@{Complete=($hivesOk -and $tokenOk -and $masksOk -and $precedenceOk -and -not $hive.SeedCreated -and -not(Test-Path -LiteralPath $files) -and $cleanupErrors.Count -eq 0);HivesRestored=$hivesOk;TokenRestored=$tokenOk;AuditMasksCompared=$beforeMasks.Count;AuditMasksRestored=$masksOk;PrecedenceRestored=$precedenceOk;HiveUnloaded=(-not $hive.Loaded);SeedRemoved=(-not $hive.SeedCreated);FilesRemoved=(-not(Test-Path -LiteralPath $files));Errors=$cleanupErrors;Failure=$(if($failure){$failure.Exception.Message}else{$null});Assertions=$script:assertions;AfterHives=$afterHives;AfterToken=$afterToken;AfterMasks=$masks;AfterPrecedence=$afterPrecedence}
Save 'cleanup.json' $cleanup
$artifacts=@(Get-ChildItem -LiteralPath $root -Recurse -File |Where-Object Name -ne 'artifact-hashes.json'|Sort-Object FullName|ForEach-Object {[pscustomobject]@{Name=$_.FullName.Substring($root.Length+1).Replace('\','/');Sha256=(Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant()}})
Save 'artifact-hashes.json' $artifacts
}
if($failure){throw $failure};if(-not $cleanup.Complete){throw ('Owned registry fixture cleanup incomplete: '+(Key $cleanup))}
Write-Host "Passed $script:assertions actual public registry SACL recovery assertions; all cleanup confirmed. Evidence: $root"
$global:LASTEXITCODE=0