Confirm AD SACL receipt ownership and validate exact PKI parent

This commit is contained in:
Shirofune-Security committed 2026-09-19 05:47:54 +09:00
1 parent 496423bb8c
commit cbd1c0643b
4 files changed
+78 -12

No files matched your search

+5 -1
View File
@@ -54,10 +54,14 @@ Both masks include CreateChild, DeleteChild, Self, WriteProperty, DeleteTree, De
See Microsoft's [certificate template schema](https://learn.microsoft.com/en-us/windows/win32/adschema/c-pkicertificatetemplate) and [enrollment service schema](https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-adsc/208d42e8-1932-4767-87c5-b8511991e69b). WriteProperty covers changes such as template attributes and an enrollment service's published `certificateTemplates` list; object creation, child objects, enrollment access rights and CA `AuditFilter` are separate. No security enforcement, enrollment permissions, CA settings, DACLs or owners are changed.
PKI parent membership is proven by a one-level LDAP lookup under the exact approved container using the target's binary object GUID. A matching textual DN suffix, including an escaped comma in an object's name, cannot establish this membership.
## Verification, concurrency and recovery
The command reads owner, group, DACL and SACL together using the critical [LDAP security descriptor flags control](https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-adts/3888c2b7-35b9-45b7-afeb-b772aa932dd0). A missing object, incomplete descriptor or permission failure is an error. It records the original complete SDDL and binary descriptor, object GUID, same-DC `uSNChanged`, target DN and requested ACEs in `before.jsonl`. A separate `ad-sacl-*.json` receipt records the exact missing ACE additions and expected descriptor **before** the write. Keep these files private and treat receipts as trusted administrator input; they contain directory security configuration.
Receipts initially have `ReceiptStatus: Pending`, which never authorizes automatic rollback. Only after the LDAP write succeeds and a fresh read verifies the original security information and requested ACEs does WELA persist `Confirmed`, the confirmation time and observed state. Confirmation replaces the intent file while retaining its `.pending` backup. A failed/stale request, failed read-back, interruption or unpersisted confirmation requires manual recovery review; another writer's later matching ACEs do not turn an unconfirmed intention into WELA-owned changes.
Existing ACEs are retained byte-for-byte. A same-scope ACE with a superset of the rights/outcomes already satisfies the request; WELA adds only missing audit ACEs. After the prompt and durable journal, WELA rereads the same DC's object GUID, USN and complete descriptor and refuses a changed object. The LDAP modify uses a **critical SACL-only** control, preserving owner/group/DACL on the server. Read-back verifies every original ACE, non-SACL security information and requested auditing; a final read detects later drift. Microsoft documents that [`uSNChanged` is local to a DC](https://learn.microsoft.com/en-us/windows/win32/adschema/a-usnchanged), so the command never substitutes another DC for these checks.
**Use an exclusive maintenance window for SACL changes.** The immediate comparison is not an atomic compare-and-swap: a concurrent writer in the final read/write window can still lose a SACL update. WELA does not claim LDAP transaction protection, lock other writers, or automatically restore a whole descriptor after an uncertain result. Stop other SACL editors/automation, review failures against the journal and current descriptor, and verify again after inheritance/replication have settled.
@@ -71,7 +75,7 @@ Existing ACEs are retained byte-for-byte. A same-scope ACE with a superset of th
-BackupPath .\new-rollback-backup -ResultsPath rollback-result.json
```
Rollback checks the DC and object identity, validates that the receipt's expected SACL contains precisely the recorded additions plus the old ACEs, and requires the current SACL to match that expected sequence. It removes only those exact additions, using the current descriptor and a SACL-only write; it never restores old owner/group/DACL data. Repeated rollback is idempotent. Reordered/merged ACEs or any intervening SACL edit make automatic ownership ambiguous and are refused. A failed or interrupted apply can leave a receipt without a completed write; inspect current state first. If automatic rollback is refused, compare the original SDDL/ACE bytes, receipt additions and current SACL on the exact DC, identify additions manually, remove only those demonstrably attributable to this run, and preserve all unrelated current entries. Do not restore the complete saved SDDL over later changes.
Rollback requires a confirmed receipt, checks the DC and object identity, validates that the receipt's expected SACL contains precisely the recorded additions plus the old ACEs, and requires the current SACL to match that expected sequence. It removes only those exact additions, using the current descriptor and a SACL-only write; it never restores old owner/group/DACL data. Repeated rollback is idempotent. Reordered/merged ACEs or any intervening SACL edit make automatic ownership ambiguous and are refused. Pending receipts are refused even if current ACEs match the intended additions. If automatic rollback is refused, compare the original SDDL/ACE bytes, receipt additions and current SACL on the exact DC, identify additions manually, remove only those demonstrably attributable to this run, and preserve all unrelated current entries. Do not restore the complete saved SDDL over later changes.
`SaclConfigured`/`Applied`/`AlreadyCompliant` refer to the **selected object's SACL**. `ChangeRequired`, `NotApplicable`, `Unknown`, `Blocked`, runner `Skipped`, `Failed` and `Overridden` remain distinct. Exit 0 means no read/write/verification failures; dry runs and skipped requests do not establish configuration. No Sigma rule uplift is claimed.
+40 -7
View File
@@ -223,6 +223,21 @@ function Write-WelaAdSacl {
$null = $Session.Connection.SendRequest($request)
}
function Test-WelaAdPkiContainer {
param($Session, $Snapshot, [string]$ContainerDn)
# A one-level GUID lookup proves parent membership without interpreting DN
# text (escaped commas can otherwise impersonate an approved suffix).
$escapedGuid = (([guid]$Snapshot.ObjectGuid).ToByteArray() | ForEach-Object { '\{0:X2}' -f $_ }) -join ''
$rows = @(Search-WelaAdDirectory -Session $Session -Dn $ContainerDn -Scope OneLevel `
-Filter "(objectGUID=$escapedGuid)" -Attributes @('objectGUID'))
if ($rows.Count -eq 0) { return $false }
if ($rows.Count -ne 1 -or $rows[0].Dn -ine $Snapshot.Dn -or
([guid]::new([byte[]](Get-WelaAdSingleValue $rows[0] 'objectGUID'))).ToString() -ne $Snapshot.ObjectGuid) {
throw 'PKI container membership lookup returned an unexpected object identity.'
}
return $true
}
function Get-WelaAdSaclPlan {
param($Session, [string[]]$Profiles, [string[]]$ObjectDn)
$requests = @()
@@ -250,9 +265,11 @@ function Get-WelaAdSaclPlan {
} else {
$before = Get-WelaAdObjectState $Session $request.Dn
$class = $null; $guid = $null
if ($before.Classes -contains 'pKICertificateTemplate' -and $before.Dn.EndsWith(",CN=Certificate Templates,CN=Public Key Services,CN=Services,$($Session.ConfigurationDn)", [StringComparison]::OrdinalIgnoreCase)) {
if ($before.Classes -contains 'pKICertificateTemplate' -and
(Test-WelaAdPkiContainer $Session $before "CN=Certificate Templates,CN=Public Key Services,CN=Services,$($Session.ConfigurationDn)")) {
$class = 'pKICertificateTemplate'; $guid = 'e5209ca2-3bba-11d2-90cc-00c04fd91ab1'
} elseif ($before.Classes -contains 'pKIEnrollmentService' -and $before.Dn.EndsWith(",CN=Enrollment Services,CN=Public Key Services,CN=Services,$($Session.ConfigurationDn)", [StringComparison]::OrdinalIgnoreCase)) {
} elseif ($before.Classes -contains 'pKIEnrollmentService' -and
(Test-WelaAdPkiContainer $Session $before "CN=Enrollment Services,CN=Public Key Services,CN=Services,$($Session.ConfigurationDn)")) {
$class = 'pKIEnrollmentService'; $guid = 'ee4aa692-3bba-11d2-90cc-00c04fd91ab1'
} else { throw 'PkiObjects accepts only existing certificate template/enrollment service objects under the selected forest PKI containers.' }
if (-not (Test-WelaAdSchemaClass $Session $class $guid)) { throw "PKI schema class missing: $class." }
@@ -295,17 +312,30 @@ function Set-WelaAdSaclControls {
param($state)
$before = $state.Observed
$addition = New-WelaAdSaclAddition $before $state.Entry.Definitions
$receipt = [ordered]@{ Version = 1; Kind = 'WelaAdSaclAddition'; Server = $state.Session.Server; Dn = $before.Dn;
ObjectGuid = $before.ObjectGuid; Before = $before; AddedAces = $addition.AddedAces; ExpectedBinary = $addition.Binary }
# A durable receipt precedes the write, including exact additions for
# conservative rollback even if the process stops after LDAP success.
$receipt = [ordered]@{ Version = 1; Kind = 'WelaAdSaclAddition'; ReceiptStatus = 'Pending'; Server = $state.Session.Server; Dn = $before.Dn;
ObjectGuid = $before.ObjectGuid; Before = $before; AddedAces = $addition.AddedAces; ExpectedBinary = $addition.Binary;
ConfirmedUtc = $null; ConfirmedAfter = $null }
# Durable intent precedes mutation, but cannot authorize rollback.
# A failed/stale request may never have written its intended ACEs.
$receiptPath = Join-Path $state.Context.BackupPath ('ad-sacl-' + [guid]::NewGuid().ToString('N') + '.json')
$receipt | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $receiptPath -Encoding UTF8 -ErrorAction Stop
$fresh = Get-WelaAdObjectState $state.Session $state.Entry.Dn
if ($fresh.ObjectGuid -ne $before.ObjectGuid -or $fresh.UsnChanged -ne $before.UsnChanged -or $fresh.Descriptor.Binary -ne $before.Descriptor.Binary) { throw 'AD object changed after journaling; no write was sent. Re-audit and retry.' }
$state.Baseline = $before
Write-WelaAdSacl $state.Session $before.Dn $addition.Binary
"SACL-only write sent; recovery receipt: $receiptPath. Event generation and inheritance propagation remain unverified."
$verified = Get-WelaAdObjectState $state.Session $before.Dn
if (-not (Test-WelaAdPreserved $before $verified)) { throw 'Existing owner, group, DACL or SACL ACE changed after writing; receipt remains Pending and requires manual recovery review.' }
foreach ($definition in $state.Entry.Definitions) {
if (-not (Test-WelaAdAcePresent $verified.Descriptor $definition)) { throw 'Requested SACL did not verify after writing; receipt remains Pending and requires manual recovery review.' }
}
$receipt.ReceiptStatus = 'Confirmed'
$receipt.ConfirmedUtc = [DateTime]::UtcNow.ToString('o')
$receipt.ConfirmedAfter = $verified
$confirmedPath = $receiptPath + '.tmp'
$receipt | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $confirmedPath -Encoding UTF8 -ErrorAction Stop
# Preserve the complete Pending receipt if confirmation cannot persist.
[IO.File]::Replace($confirmedPath, $receiptPath, ($receiptPath + '.pending'))
"SACL-only write and read-back verified; confirmed recovery receipt: $receiptPath. Event generation and inheritance propagation remain unverified."
}
Invoke-WelaConfigurationControl -Context $Context -Id $id -Kind AdObjectSacl -Target @{ Server = $Session.Server; Dn = $entry.Dn } `
-Desired $entry.Definitions -Read $read -Compliant $test -Apply $apply -CallbackState $state `
@@ -318,6 +348,9 @@ function Invoke-WelaAdSaclRollback {
$receipt = Get-Content -LiteralPath $ReceiptPath -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop
if ($receipt.Version -ne 1 -or $receipt.Kind -ne 'WelaAdSaclAddition' -or $receipt.Server -ine $Session.Server -or
-not $receipt.AddedAces.Count -or $receipt.ObjectGuid -ne $receipt.Before.ObjectGuid -or $receipt.Dn -ine $receipt.Before.Dn) { throw 'Invalid receipt, empty additions, or a different DC target.' }
if ($receipt.ReceiptStatus -ne 'Confirmed' -or -not $receipt.ConfirmedUtc -or
$receipt.ConfirmedAfter.ObjectGuid -ne $receipt.ObjectGuid -or $receipt.ConfirmedAfter.Server -ine $Session.Server -or
$receipt.ConfirmedAfter.Dn -ine $receipt.Dn) { throw 'Unconfirmed receipt: automatic rollback cannot establish ACE ownership. Review Pending/failed/interrupted changes manually.' }
$expected = Get-WelaAdDescriptorInfo $receipt.ExpectedBinary
# Receipts are recovery evidence, not a general descriptor-restore mechanism.
$remaining = New-Object 'System.Collections.Generic.List[string]'
+24 -2
View File
@@ -20,7 +20,7 @@ $script:originalInfo = ${function:Get-WelaAdDescriptorInfo}
function Reset-Mocks {
$script:writes = 0; $script:reads = 0; $script:readError = $false; $script:writeError = $false
$script:race = $false; $script:finalDrift = $false; $script:badReadback = $false; $script:removeExisting = $false
$script:absentClass = ''; $script:dmsa = $true; $script:exchange = $true; $script:onPrompt = $null
$script:absentClass = ''; $script:dmsa = $true; $script:exchange = $true; $script:onPrompt = $null; $script:pkiMember = $true; $script:pkiRequest = $null
$script:state = [pscustomobject]@{ Server = $session.Server; Dn = $session.DomainDn; ObjectGuid = '01234567-89ab-cdef-0123-456789abcdef'; UsnChanged = '17'; Classes = @('top', 'domainDNS');
Descriptor = [pscustomobject]@{ Binary = 'before'; Sddl = 'O:SYG:SYD:(A;;GA;;;SY)S:(AU;SA;WP;;;BA)'; Owner = 'S-1-5-18'; Group = 'S-1-5-18'; Dacl = 'unchanged-dacl'; ControlFlags = 32788; Sacl = @('unrelated') } }
$session.Writable = $true
@@ -30,6 +30,10 @@ function Test-WelaAdDmsaDomain { param($Session) return $script:dmsa }
function Search-WelaAdDirectory {
param($Session, $Dn, $Filter, $Scope, $Attributes, [switch]$SecurityDescriptor)
if ($Filter -eq '(objectClass=msExchOrganizationContainer)' -and $script:exchange) { [pscustomobject]@{ Dn = 'CN=Exchange'; Values = @{} } }
if ($Filter -like '(objectGUID=*') {
$script:pkiRequest = @{ Dn = $Dn; Scope = $Scope; Filter = $Filter }
if ($script:pkiMember) { [pscustomobject]@{ Dn = $script:state.Dn; Values = @{ objectGUID = @(,([guid]$script:state.ObjectGuid).ToByteArray()) } } }
}
}
function Get-WelaAdObjectState {
param($Session, $Dn)
@@ -37,7 +41,7 @@ function Get-WelaAdObjectState {
if ($script:readError) { throw 'LDAP access denied' }
$snapshot = $script:state | ConvertTo-Json -Depth 10 | ConvertFrom-Json
if ($script:race -and $script:reads -ge 3) { $snapshot.UsnChanged = '99' }
if ($script:finalDrift -and $script:reads -ge 5) { $snapshot.Descriptor.Sacl = @('unrelated'); $snapshot.Descriptor.Binary = 'drift' }
if ($script:finalDrift -and $script:reads -ge 6) { $snapshot.Descriptor.Sacl = @('unrelated'); $snapshot.Descriptor.Binary = 'drift' }
return $snapshot
}
function Test-WelaAdAcePresent { param($Descriptor, $Definition) return $Descriptor.Sacl -contains ('added-' + $Definition.Class) }
@@ -90,8 +94,13 @@ try {
$script:state.Classes = @('top', 'pKICertificateTemplate')
$plan = @(Get-WelaAdSaclPlan $session @('PkiObjects') @($script:state.Dn))
Assert ($plan[0].Status -eq 'ChangeRequired' -and $plan[0].Definitions[0].AccessMask -eq 852000 -and $plan[0].Definitions[0].AceFlags -eq 64) 'PKI direct-object write/delete/ACL audit only'
Assert ($script:pkiRequest.Scope -eq 'OneLevel' -and $script:pkiRequest.Dn -eq "CN=Certificate Templates,CN=Public Key Services,CN=Services,$($session.ConfigurationDn)" -and
$script:pkiRequest.Filter -eq '(objectGUID=\67\45\23\01\AB\89\EF\CD\01\23\45\67\89\AB\CD\EF)') 'PKI membership uses exact parent and byte-escaped object GUID'
$script:pkiMember = $false
$script:state.Dn = "CN=Test,$($session.DomainDn)"
Assert ((@(Get-WelaAdSaclPlan $session @('PkiObjects') @($script:state.Dn)))[0].Status -eq 'Unknown') 'PKI class outside trusted forest containers refused'
$script:state.Dn = "CN=Foo\,CN=Certificate Templates,CN=Public Key Services,CN=Services,$($session.ConfigurationDn)"
Assert ((@(Get-WelaAdSaclPlan $session @('PkiObjects') @($script:state.Dn)))[0].Status -eq 'Unknown') 'escaped RDN suffix cannot impersonate approved PKI parent'
Reset-Mocks; $session.Writable = $false
Assert ((@(Get-WelaAdSaclPlan $session @('MdiDomain') @()))[0].Status -eq 'Blocked') 'RODC plan explicitly blocked'
Reset-Mocks; $script:readError = $true
@@ -108,13 +117,26 @@ try {
$receipts = @(Get-ChildItem $ctx.BackupPath -Filter 'ad-sacl-*.json')
$receipt = Get-Content $receipts[0].FullName -Raw | ConvertFrom-Json
Assert ($receipt.AddedAces.Count -eq 6 -and $receipt.Before.Descriptor.Binary -eq 'before') 'receipt stores only intended additions and before binary'
Assert ($receipt.ReceiptStatus -eq 'Confirmed' -and $receipt.ConfirmedAfter.Descriptor.Binary -eq 'after' -and $receipt.ConfirmedUtc) 'successful write and readback confirm rollback ownership'
$ctx2 = Get-Context; $report2 = Invoke-TestConfigure $ctx2
Assert ($script:writes -eq 1 -and $report2.Results[0].Status -eq 'AlreadyCompliant') 'repeat run is idempotent'
Reset-Mocks; $script:race = $true; $ctx = Get-Context
$report = Invoke-TestConfigure $ctx
Assert ($report.ExitCode -eq 1 -and $script:writes -eq 0 -and $report.Results[0].Diagnostic -like '*changed after journaling*') 'USN race fails closed before write'
$pendingPath = @(Get-ChildItem $ctx.BackupPath -Filter 'ad-sacl-*.json')[0].FullName
Assert ((Get-Content $pendingPath -Raw | ConvertFrom-Json).ReceiptStatus -eq 'Pending') 'stale pre-write receipt remains pending'
$script:state.Descriptor.Sacl = @('unrelated') + @(Get-WelaAdAuditDefinitions | ForEach-Object { 'added-' + $_.Class })
$script:state.Descriptor.Binary = 'after'
Assert-Throws { Invoke-WelaAdSaclRollback $session (Get-Context $true) $pendingPath } 'pending intent cannot authorize rollback of another writers matching ACEs'
Reset-Mocks; $script:writeError = $true; $ctx = Get-Context
$report = Invoke-TestConfigure $ctx
$pendingPath = @(Get-ChildItem $ctx.BackupPath -Filter 'ad-sacl-*.json')[0].FullName
Assert ($report.ExitCode -eq 1 -and (Get-Content $pendingPath -Raw | ConvertFrom-Json).ReceiptStatus -eq 'Pending') 'failed LDAP write cannot confirm receipt'
Assert-Throws { Invoke-WelaAdSaclRollback $session (Get-Context $true) $pendingPath } 'failed-write receipt cannot authorize automatic rollback'
Reset-Mocks; $script:badReadback = $true; $ctx = Get-Context
Assert ((Invoke-TestConfigure $ctx).ExitCode -eq 1) 'missing audit ACE readback fails'
$pendingPath = @(Get-ChildItem $ctx.BackupPath -Filter 'ad-sacl-*.json')[0].FullName
Assert ((Get-Content $pendingPath -Raw | ConvertFrom-Json).ReceiptStatus -eq 'Pending') 'failed readback leaves receipt pending'
Reset-Mocks; $script:removeExisting = $true; $ctx = Get-Context
$report = Invoke-TestConfigure $ctx
Assert ($report.ExitCode -eq 1 -and $report.Results[0].Diagnostic -like '*Existing owner*') 'loss of pre-existing audit ACE fails verification'
+9 -2
View File
@@ -66,13 +66,15 @@ try {
# Mock the state/transport boundary, retain real ACL transformations and runner.
$script:state = $after; $script:writes = 0
function Get-WelaAdObjectState { param($Session, $Dn) return $script:state }
function Get-WelaAdObjectState { param($Session, $Dn) return ($script:state | ConvertTo-Json -Depth 12 | ConvertFrom-Json) }
function Write-WelaAdSacl { param($Session, $Dn, $Binary)
$script:writes++; $script:state.Descriptor = Get-WelaAdDescriptorInfo $Binary; $script:state.UsnChanged = '101'
if ($script:corruptRollback) { $script:state.Descriptor.Owner = 'S-1-5-19' }
}
$session.Writable = $true
$receiptPath = Join-Path $root 'receipt.json'
[pscustomobject]@{ Version = 1; Kind = 'WelaAdSaclAddition'; Server = $before.Server; Dn = $before.Dn; ObjectGuid = $before.ObjectGuid;
[pscustomobject]@{ Version = 1; Kind = 'WelaAdSaclAddition'; ReceiptStatus = 'Confirmed'; ConfirmedUtc = [DateTime]::UtcNow.ToString('o');
ConfirmedAfter = $after; Server = $before.Server; Dn = $before.Dn; ObjectGuid = $before.ObjectGuid;
Before = $before; AddedAces = $addition.AddedAces; ExpectedBinary = $addition.Binary } | ConvertTo-Json -Depth 12 | Set-Content $receiptPath -Encoding UTF8
$ctx = New-WelaConfigurationContext -Auto -BackupPath (Join-Path $root 'rollback')
Invoke-WelaAdSaclRollback $session $ctx $receiptPath
@@ -89,5 +91,10 @@ try {
$ctx = New-WelaConfigurationContext -Auto -BackupPath (Join-Path $root 'rollback-drift')
Invoke-WelaAdSaclRollback $session $ctx $receiptPath
Assert ($ctx.Results[0].Status -eq 'Failed' -and $script:writes -eq 1) 'rollback refuses ambiguous intervening SACL changes'
$script:state.Descriptor = Get-WelaAdDescriptorInfo $addition.Binary
$script:corruptRollback = $true
$ctx = New-WelaConfigurationContext -Auto -BackupPath (Join-Path $root 'rollback-corruption')
Invoke-WelaAdSaclRollback $session $ctx $receiptPath
Assert ($ctx.Results[0].Status -eq 'Failed' -and $ctx.Results[0].Diagnostic -like '*Owner/group/DACL*') 'independent rollback snapshot detects owner corruption'
Write-Host "Passed $script:checks native Windows AD descriptor/LDAP tests. No AD bind or live mutation occurred."
} finally { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue }