Skip to content

Commit 59d3c25

Browse files
Den-Secclaude
andcommitted
F6: deployment tooling, GPO template, and documentation
- deploy/Install-PasswordFilter.ps1: copies the DLL to System32, writes default policy, registers the Event Log source, and adds the package to LSA Notification Packages MERGE-SAFE (existing packages preserved; re-runs never clobber tuned values). - deploy/Uninstall-PasswordFilter.ps1: merge-safe removal + cleanup, with reboot guidance. - deploy/Test-PasswordFilter.ps1: lab helper that exercises resets and dumps the events. - deploy/PasswordFilter.admx + en-US/PasswordFilter.adml: Group Policy template. - deploy/policy.example.reg, blacklist.example.txt, company_terms.example.txt. - docs/ARCHITECTURE.md, DEPLOYMENT.md, CONFIG.md and SECURITY.md: LSA contract, core/shim split, evaluation order, Bloom design, rollout/rollback, DSRM recovery, full config reference, threat model and fail-open guarantees. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 3d13fc6 commit 59d3c25

13 files changed

Lines changed: 788 additions & 1 deletion

README.md

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,29 @@ ctest --test-dir build -C Release --output-on-failure
6464
The pure `pwfilter_core` library and its tests also build on any C++17 compiler (no
6565
Windows headers required), which is what makes the policy logic portable and testable.
6666

67+
## Deploy (lab only)
68+
69+
> Validate on a non-production Domain Controller first - this runs in LSASS.
70+
71+
```powershell
72+
# 1. Build the offline breach artifact once (Python only) from the HIBP dump:
73+
python scripts\build_bloom.py pwnedpasswords.txt -o breach.bloom --count 1300000000
74+
75+
# 2. Install on a test DC (copies the DLL, sets policy, registers with LSA - merge-safe):
76+
.\deploy\Install-PasswordFilter.ps1 -DllSource .\PasswordFilterDLL.dll
77+
# place breach.bloom in %ProgramData%\PasswordFilter\, then REBOOT
78+
79+
# 3. Exercise it and read the Event Log:
80+
.\deploy\Test-PasswordFilter.ps1 -SamAccountName test.user
81+
82+
# Rollback:
83+
.\deploy\Uninstall-PasswordFilter.ps1
84+
```
85+
86+
Full guides: [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md) · [docs/CONFIG.md](docs/CONFIG.md) ·
87+
[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) · [SECURITY.md](SECURITY.md). GPO admins: an
88+
ADMX/ADML template ships in [`deploy/`](deploy/).
89+
6790
## Layout
6891

6992
| Path | What |
@@ -83,7 +106,7 @@ Windows headers required), which is what makes the policy logic portable and tes
83106
- [x] **F3** - Core: breach checker (SHA-1 → Bloom)
84107
- [x] **F4** - Python Bloom builder + sample data + cross-language format test
85108
- [x] **F5** - LSASS shim: fail-safe `dllmain`, registry/file config, Event Log
86-
- [ ] **F6** - Deployment scripts, ADMX, full documentation
109+
- [x] **F6** - Deployment scripts, ADMX, full documentation
87110
- [ ] **F7** - Optional online HIBP k-anonymity checker (admin tool, out of LSASS)
88111

89112
## Security

SECURITY.md

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
# Security
2+
3+
PasswordFilterDLL is a defensive tool that runs **inside LSASS** on a Domain Controller -
4+
the most privileged process on the box. This document states the threat model, the safety
5+
rules the code follows, how to recover if a filter ever misbehaves, and how to report
6+
issues.
7+
8+
## Safety model
9+
10+
A password filter sits on the critical path of every password change for the whole domain.
11+
Two failure modes matter most: **crashing LSASS** (which bugchecks the machine and can
12+
cause a boot loop on a DC) and **wrongly rejecting passwords** (which can lock users out).
13+
The design mitigates both:
14+
15+
- **Fail open on internal error.** Every callback wraps its work in *both* a structured
16+
(`__try/__except`) and a C++ (`try/catch`) handler. Any unexpected fault - access
17+
violation, allocation failure, a corrupt data file - results in the change being
18+
**allowed** and a warning logged, never an unhandled exception escaping into LSASS. The
19+
filter rejects **only** on an explicit policy match. (`FailOpenOnError`, default on.)
20+
- **No plaintext, anywhere.** The password is read in place from LSA's `UNICODE_STRING`
21+
as a read-only view; it is never copied onto our heap and never written to disk or logs.
22+
The only derived copies are short-lived: the UTF-8 bytes hashed for the breach check and
23+
the normalized key for the blacklist lookup, each held in a buffer that is wiped with
24+
`SecureZero` before it is released. The Event Log records **metadata only** (account
25+
name, operation, the rule that failed, counts).
26+
- **Correct `UNICODE_STRING` handling.** `Length` is a count of **bytes** and the buffer is
27+
**not** NUL-terminated; treating it as a C string is the classic bug in this API. The
28+
shim always uses the length.
29+
- **Fast path.** All data (the Bloom filter, blacklist, company terms) is loaded once at
30+
initialization. Per-password work is a handful of in-memory checks plus one SHA-1 and a
31+
Bloom lookup over a memory-mapped, read-only region.
32+
- **Small, auditable shim.** All decision logic lives in `pwfilter_core`, a pure,
33+
unit-tested C++ library with no Windows dependency. The LSASS-resident code only
34+
marshals arguments, applies the verdict, and logs.
35+
36+
## False positives (breach Bloom)
37+
38+
The breach check uses a Bloom filter. A false positive means a password that is **not**
39+
breached is treated as if it were and gets rejected - the user simply picks another. There
40+
are **no false negatives**: a breached password is never accepted. The default sizing
41+
targets a ~0.1% false-positive rate.
42+
43+
## Recovery: removing a misbehaving filter
44+
45+
If a filter ever has to be removed urgently (for example it is wrongly rejecting all
46+
changes, or you suspect it of instability):
47+
48+
1. **Detach cleanly:** run `deploy/Uninstall-PasswordFilter.ps1` (removes the package from
49+
`Notification Packages`, merge-safe) and reboot.
50+
2. **If the DC will not boot** (a filter crashing LSASS): boot into **Directory Services
51+
Restore Mode (DSRM)** or another recovery environment, load the SYSTEM hive, and remove
52+
`PasswordFilterDLL` from
53+
`HKLM\SYSTEM\CurrentControlSet\Control\Lsa\Notification Packages`, then reboot.
54+
55+
Because the filter fails open by default, an internal bug degrades to "no filtering," not
56+
"no logins" - but always validate in a lab first.
57+
58+
## Testing
59+
60+
Test **only** on a non-production lab Domain Controller (or a standalone test box). Never
61+
register an unproven filter on a production DC. `pwfilter_core` is unit-tested off-host and
62+
in CI; the LSASS integration is validated in a lab VM (see `docs/DEPLOYMENT.md`).
63+
64+
## Reporting a vulnerability
65+
66+
Please report security issues privately rather than opening a public issue: open a GitHub
67+
security advisory on the repository, or contact the maintainer via the address on the
68+
[Den-Sec](https://github.com/Den-Sec) profile. Include reproduction steps and affected
69+
versions. Coordinated disclosure is appreciated.

deploy/Install-PasswordFilter.ps1

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
<#
2+
.SYNOPSIS
3+
Installs PasswordFilterDLL as an LSA notification package on this machine.
4+
5+
.DESCRIPTION
6+
Copies the DLL into System32, registers default policy in the registry, registers the
7+
Windows Event Log source, and adds the package to the LSA "Notification Packages"
8+
value (MERGE-SAFE: existing packages such as rassfm/scecli are preserved). A REBOOT is
9+
required for LSASS to load the filter.
10+
11+
Run this on a TEST Domain Controller in a lab first. Never test on production.
12+
Deploy to EVERY Domain Controller (each DC enforces password changes locally).
13+
14+
.PARAMETER DllSource
15+
Path to PasswordFilterDLL.dll (defaults to the script directory).
16+
17+
.PARAMETER DataDir
18+
Directory for breach.bloom / blacklist.txt / company_terms.txt
19+
(default: %ProgramData%\PasswordFilter).
20+
21+
.PARAMETER NoReboot
22+
Skip the reboot prompt (you must reboot manually before the filter is active).
23+
24+
.EXAMPLE
25+
.\Install-PasswordFilter.ps1 -DllSource .\PasswordFilterDLL.dll
26+
#>
27+
#Requires -RunAsAdministrator
28+
[CmdletBinding()]
29+
param(
30+
[string]$DllSource = (Join-Path $PSScriptRoot 'PasswordFilterDLL.dll'),
31+
[string]$DataDir = (Join-Path $env:ProgramData 'PasswordFilter'),
32+
[switch]$NoReboot
33+
)
34+
35+
$ErrorActionPreference = 'Stop'
36+
$PackageName = 'PasswordFilterDLL' # name WITHOUT .dll, for LSA
37+
$DllDest = Join-Path $env:WINDIR "System32\$PackageName.dll"
38+
$LsaKey = 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa'
39+
$CfgKey = 'HKLM:\SOFTWARE\Den-Sec\PasswordFilter'
40+
$SrcKey = 'HKLM:\SYSTEM\CurrentControlSet\Services\EventLog\Application\Den-Sec PasswordFilter'
41+
42+
if (-not (Test-Path -LiteralPath $DllSource)) {
43+
throw "DLL not found: $DllSource"
44+
}
45+
46+
Write-Host "[1/5] Copying DLL to $DllDest"
47+
Copy-Item -LiteralPath $DllSource -Destination $DllDest -Force
48+
49+
Write-Host "[2/5] Preparing data directory $DataDir"
50+
New-Item -ItemType Directory -Force -Path $DataDir | Out-Null
51+
foreach ($pair in @(
52+
@{ Example = 'blacklist.example.txt'; Target = 'blacklist.txt' },
53+
@{ Example = 'company_terms.example.txt'; Target = 'company_terms.txt' })) {
54+
$ex = Join-Path $PSScriptRoot $pair.Example
55+
$tg = Join-Path $DataDir $pair.Target
56+
if ((Test-Path -LiteralPath $ex) -and -not (Test-Path -LiteralPath $tg)) {
57+
Copy-Item -LiteralPath $ex -Destination $tg
58+
}
59+
}
60+
Write-Host " NOTE: build breach.bloom with scripts/build_bloom.py and place it in $DataDir"
61+
62+
Write-Host "[3/5] Writing default policy to $CfgKey"
63+
New-Item -Path $CfgKey -Force | Out-Null
64+
$defaults = [ordered]@{
65+
MinLength = 12
66+
MaxLength = 256
67+
RequiredClasses = 3
68+
RejectKeyboardPatterns = 1
69+
RejectSequences = 1
70+
SequenceMinRun = 4
71+
KeyboardMinRun = 4
72+
MaxRepeatRun = 4
73+
RejectContainsAccountName = 1
74+
RejectContainsFullName = 1
75+
MinIdentityToken = 3
76+
CheckBreach = 1
77+
FailOpenOnError = 1
78+
BlacklistCaseInsensitive = 1
79+
}
80+
foreach ($name in $defaults.Keys) {
81+
# Only set if missing, so re-running the installer never clobbers tuned values.
82+
if ($null -eq (Get-ItemProperty -Path $CfgKey -Name $name -ErrorAction SilentlyContinue)) {
83+
New-ItemProperty -Path $CfgKey -Name $name -Value $defaults[$name] -PropertyType DWord -Force | Out-Null
84+
}
85+
}
86+
New-ItemProperty -Path $CfgKey -Name 'DataDir' -Value $DataDir -PropertyType String -Force | Out-Null
87+
88+
Write-Host "[4/5] Registering Event Log source"
89+
New-Item -Path $SrcKey -Force | Out-Null
90+
New-ItemProperty -Path $SrcKey -Name 'EventMessageFile' -Value $DllDest -PropertyType ExpandString -Force | Out-Null
91+
New-ItemProperty -Path $SrcKey -Name 'TypesSupported' -Value 7 -PropertyType DWord -Force | Out-Null
92+
93+
Write-Host "[5/5] Registering with LSA Notification Packages (merge-safe)"
94+
$current = (Get-ItemProperty -Path $LsaKey -Name 'Notification Packages' -ErrorAction SilentlyContinue).'Notification Packages'
95+
if ($null -eq $current) { $current = @() }
96+
if ($current -notcontains $PackageName) {
97+
$updated = @($current) + $PackageName
98+
Set-ItemProperty -Path $LsaKey -Name 'Notification Packages' -Value $updated -Type MultiString
99+
Write-Host " Added '$PackageName'. Packages now: $($updated -join ', ')"
100+
} else {
101+
Write-Host " '$PackageName' already registered. Packages: $($current -join ', ')"
102+
}
103+
104+
Write-Host ""
105+
Write-Host "Install complete. A REBOOT is required for LSASS to load the filter." -ForegroundColor Yellow
106+
if (-not $NoReboot) {
107+
$answer = Read-Host "Reboot now? (y/N)"
108+
if ($answer -eq 'y') { Restart-Computer -Force }
109+
}

deploy/PasswordFilter.admx

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<!-- PasswordFilterDLL Group Policy template.
3+
Copy PasswordFilter.admx to %SystemRoot%\PolicyDefinitions (or the domain Central
4+
Store \\<domain>\SYSVOL\<domain>\Policies\PolicyDefinitions) and PasswordFilter.adml
5+
to the matching en-US subfolder. Policies write under
6+
HKLM\SOFTWARE\Den-Sec\PasswordFilter and take effect on the next reboot. -->
7+
<policyDefinitions revision="1.0" schemaVersion="1.0"
8+
xmlns="http://schemas.microsoft.com/GroupPolicy/2006/07/PolicyDefinitions">
9+
<policyNamespaces>
10+
<target prefix="pwfilter" namespace="DenSec.Policies.PasswordFilter" />
11+
<using prefix="windows" namespace="Microsoft.Policies.Windows" />
12+
</policyNamespaces>
13+
<resources minRequiredRevision="1.0" />
14+
<categories>
15+
<category name="PasswordFilter" displayName="$(string.Cat_PasswordFilter)" />
16+
</categories>
17+
<policies>
18+
19+
<policy name="MinLength" class="Machine" displayName="$(string.MinLength)"
20+
explainText="$(string.MinLength_Help)" key="SOFTWARE\Den-Sec\PasswordFilter"
21+
presentation="$(presentation.MinLength)">
22+
<parentCategory ref="PasswordFilter" />
23+
<supportedOn ref="windows:SUPPORTED_Windows7" />
24+
<elements>
25+
<decimal id="MinLength_Val" valueName="MinLength" minValue="1" maxValue="256" />
26+
</elements>
27+
</policy>
28+
29+
<policy name="MaxLength" class="Machine" displayName="$(string.MaxLength)"
30+
explainText="$(string.MaxLength_Help)" key="SOFTWARE\Den-Sec\PasswordFilter"
31+
presentation="$(presentation.MaxLength)">
32+
<parentCategory ref="PasswordFilter" />
33+
<supportedOn ref="windows:SUPPORTED_Windows7" />
34+
<elements>
35+
<decimal id="MaxLength_Val" valueName="MaxLength" minValue="8" maxValue="256" />
36+
</elements>
37+
</policy>
38+
39+
<policy name="RequiredClasses" class="Machine" displayName="$(string.RequiredClasses)"
40+
explainText="$(string.RequiredClasses_Help)" key="SOFTWARE\Den-Sec\PasswordFilter"
41+
presentation="$(presentation.RequiredClasses)">
42+
<parentCategory ref="PasswordFilter" />
43+
<supportedOn ref="windows:SUPPORTED_Windows7" />
44+
<elements>
45+
<decimal id="RequiredClasses_Val" valueName="RequiredClasses" minValue="0" maxValue="4" />
46+
</elements>
47+
</policy>
48+
49+
<policy name="MaxRepeatRun" class="Machine" displayName="$(string.MaxRepeatRun)"
50+
explainText="$(string.MaxRepeatRun_Help)" key="SOFTWARE\Den-Sec\PasswordFilter"
51+
presentation="$(presentation.MaxRepeatRun)">
52+
<parentCategory ref="PasswordFilter" />
53+
<supportedOn ref="windows:SUPPORTED_Windows7" />
54+
<elements>
55+
<decimal id="MaxRepeatRun_Val" valueName="MaxRepeatRun" minValue="0" maxValue="64" />
56+
</elements>
57+
</policy>
58+
59+
<policy name="RejectKeyboardPatterns" class="Machine"
60+
displayName="$(string.RejectKeyboardPatterns)" explainText="$(string.RejectKeyboardPatterns_Help)"
61+
key="SOFTWARE\Den-Sec\PasswordFilter" valueName="RejectKeyboardPatterns">
62+
<parentCategory ref="PasswordFilter" />
63+
<supportedOn ref="windows:SUPPORTED_Windows7" />
64+
<enabledValue><decimal value="1" /></enabledValue>
65+
<disabledValue><decimal value="0" /></disabledValue>
66+
</policy>
67+
68+
<policy name="RejectSequences" class="Machine" displayName="$(string.RejectSequences)"
69+
explainText="$(string.RejectSequences_Help)" key="SOFTWARE\Den-Sec\PasswordFilter"
70+
valueName="RejectSequences">
71+
<parentCategory ref="PasswordFilter" />
72+
<supportedOn ref="windows:SUPPORTED_Windows7" />
73+
<enabledValue><decimal value="1" /></enabledValue>
74+
<disabledValue><decimal value="0" /></disabledValue>
75+
</policy>
76+
77+
<policy name="RejectContainsAccountName" class="Machine"
78+
displayName="$(string.RejectContainsAccountName)"
79+
explainText="$(string.RejectContainsAccountName_Help)"
80+
key="SOFTWARE\Den-Sec\PasswordFilter" valueName="RejectContainsAccountName">
81+
<parentCategory ref="PasswordFilter" />
82+
<supportedOn ref="windows:SUPPORTED_Windows7" />
83+
<enabledValue><decimal value="1" /></enabledValue>
84+
<disabledValue><decimal value="0" /></disabledValue>
85+
</policy>
86+
87+
<policy name="RejectContainsFullName" class="Machine"
88+
displayName="$(string.RejectContainsFullName)"
89+
explainText="$(string.RejectContainsFullName_Help)"
90+
key="SOFTWARE\Den-Sec\PasswordFilter" valueName="RejectContainsFullName">
91+
<parentCategory ref="PasswordFilter" />
92+
<supportedOn ref="windows:SUPPORTED_Windows7" />
93+
<enabledValue><decimal value="1" /></enabledValue>
94+
<disabledValue><decimal value="0" /></disabledValue>
95+
</policy>
96+
97+
<policy name="CheckBreach" class="Machine" displayName="$(string.CheckBreach)"
98+
explainText="$(string.CheckBreach_Help)" key="SOFTWARE\Den-Sec\PasswordFilter"
99+
valueName="CheckBreach">
100+
<parentCategory ref="PasswordFilter" />
101+
<supportedOn ref="windows:SUPPORTED_Windows7" />
102+
<enabledValue><decimal value="1" /></enabledValue>
103+
<disabledValue><decimal value="0" /></disabledValue>
104+
</policy>
105+
106+
<policy name="FailOpenOnError" class="Machine" displayName="$(string.FailOpenOnError)"
107+
explainText="$(string.FailOpenOnError_Help)" key="SOFTWARE\Den-Sec\PasswordFilter"
108+
valueName="FailOpenOnError">
109+
<parentCategory ref="PasswordFilter" />
110+
<supportedOn ref="windows:SUPPORTED_Windows7" />
111+
<enabledValue><decimal value="1" /></enabledValue>
112+
<disabledValue><decimal value="0" /></disabledValue>
113+
</policy>
114+
115+
<policy name="DataDir" class="Machine" displayName="$(string.DataDir)"
116+
explainText="$(string.DataDir_Help)" key="SOFTWARE\Den-Sec\PasswordFilter"
117+
presentation="$(presentation.DataDir)">
118+
<parentCategory ref="PasswordFilter" />
119+
<supportedOn ref="windows:SUPPORTED_Windows7" />
120+
<elements>
121+
<text id="DataDir_Val" valueName="DataDir" />
122+
</elements>
123+
</policy>
124+
125+
</policies>
126+
</policyDefinitions>

0 commit comments

Comments
 (0)