Skip to content

Review fixes: installer lockout, LSA contracts, lsass-safe AP, tests for the lsass code - #1

Merged
Nobrac merged 7 commits into
mainfrom
review-fixes-lsa
Oct 11, 2026
Merged

Nobrac merged 7 commits into
mainfrom
review-fixes-lsa

Conversation

@Nobrac

@Nobrac Nobrac commented Oct 11, 2026 •

Copy link
Copy Markdown
Owner

Fixes the findings of the code review of Part 1 and Part 2 (H1, M1–M6, N1–N10). Six commits, each of which builds and passes its tests on its own.

What changes

H1 – installer locked out the break-glass admin. install-doppio.ps1 imported register-filter.reg automatically and advised keeping a second admin unenrolled, who then had no way in at the console or over RDP. The filter is now a separate -EnableFilter step that refuses while an enabled local account is not enrolled (-AllowUnenrolled for service accounts). The CLSID check also covers HKLM\SOFTWARE\Classes\CLSID and the filter key.

M1/M2 – authentication package (ap.cpp). Rebuilt on the same lsass rules as the filter: no heap or CRT of its own, SEH around the decision, unreadable index = deny. AccountName is always returned from the LSA heap, as LSA_AP_LOGON_USER_EX2 requires. Status codes are now the documented ones (STATUS_ACCOUNT_RESTRICTION with SubStatus STATUS_INVALID_WORKSTATION, STATUS_INVALID_LOGON_TYPE, STATUS_NO_MEMORY). The user name is read as a client address or a packed offset (N2).

M3/M4/N5 – sub-auth filter (subauth.cpp). Authoritative and UserFlags are set on every path. The veto is STATUS_INVALID_WORKSTATION, which is in the documented list. MSV1_0_PASSTHRU gets no objection. LogoffTime/KickoffTime stay untouched, and every call logs the incoming values, so the VM test can settle the open question. install-subauth.ps1 refuses on a DC.

Store. The index reads that run in lsass moved to store_index.cpp. The two LSA DLLs now link only that file and advapi32, with no DPAPI, TOTP or bcrypt code in lsass. On the object files, the only unresolved symbols are Windows imports, __C_specific_handler and memset. Locale-invariant case folding (N8). QueryAnyEnrollment counts real secrets only (M6). The DACL repair moved from every SaveState to enroll.exe (N9).

Filter/enroll (N4, M6). UpdateRemoteCredential leaves RDP credentials alone while nothing is enrolled, and leaves smartcard serializations with their own provider. New enroll /list shows orphaned secrets of deleted accounts, and enroll /remove <SID> removes them.

Scripts (N1, N7). LSA install scripts also check WinInit event 12 since the last boot, because the Win11 default-on case is not documented to appear in RunAsPPL. The texts note that Auth0 loads on first use.

Tests/CI (N10). New tests/native/test_lsa runs the real subauth.cpp, ap.cpp and store_index.cpp against a fake registry, event log and LSA heap under ASan/UBSan. totp_check.py can now fail CI. CI parses every .ps1, adds /CETCOMPAT, and runs /analyze /WX over the lsass code.

Verified here

  • All 14 .cpp files: clang syntax check against the mingw-w64 Windows headers, 0 errors, no new warnings.
  • make -C tests/native: both suites pass. Re-introducing six of the old defects one at a time makes test_lsa fail each time.
  • totp_check.py exits 1 on a wrong RFC vector and on a broken Base32 decoder.
  • CI on this branch: all steps green – MSVC build with /W4 /sdl /guard:cf /CETCOMPAT, CFG check, /analyze /WX over the lsass code, PowerShell parse of every script, both native suites, Python check.

Not verified – please check

  • PowerShell behaviour. The scripts parse, but none was run on Windows.
  • Live LSA. Still untested, as before. In the VM, the [Doppio-SubAuth] lines now show what LogoffTime/KickoffTime hold and which LogonLevel a NetworkCleartext (type 8) logon arrives with.
  • Behaviour change: the filter's veto code changed from STATUS_ACCOUNT_RESTRICTION to STATUS_INVALID_WORKSTATION (Microsoft's description: "The user is not authorized to log on to the specified workstation"), so a client shows a different error text. The article update in theadmincafe-nuxt explains it.
  • Ristretto: its submodule pins Doppio at 2776655. When it moves to this branch, build-ristretto.bat has to add %DOPPIO%\store_index.cpp next to %DOPPIO%\store.cpp, or ristretto.exe will not link.

🤖 Generated with Claude Code

https://claude.ai/code/session_015Q8WTBzhJ9rZXAXLe89ssH


Generated by Claude Code

…heck

install-doppio.ps1 imported register-filter.reg whenever the file sat next
to it, so the filter went live with the first enrollment. Its closing
advice - keep a second admin that is NOT enrolled - then locked exactly
that admin out of the console and RDP: no secret for its 2FA tile, and
every other tile hidden.

Now the default run only installs the provider. -EnableFilter is a
separate step and refuses while an enabled local account is not enrolled
(-AllowUnenrolled names accounts that never sign in interactively). The
closing message says to enroll the break-glass admin too. The CLSID check
also looks at HKLM\SOFTWARE\Classes\CLSID, and -EnableFilter at the filter
key, before anything is overwritten.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Q8WTBzhJ9rZXAXLe89ssH
Authentication package (ap.cpp):
- No heap or CRT of its own on the logon path (Buf instead of std::wstring
  and std::to_wstring), SEH around the decision instead of catch (...),
  which does not catch an access violation under /EHsc.
- AccountName is always returned from the LSA heap, as LSA_AP_LOGON_USER_EX2
  requires, also for interactive logons. It used to stay null.
- Only documented status codes: STATUS_ACCOUNT_RESTRICTION with SubStatus
  STATUS_INVALID_WORKSTATION to deny, STATUS_INVALID_LOGON_TYPE to decline,
  STATUS_NO_MEMORY on allocation failure.
- The user name is read as a client address or as a packed offset, the
  form Winlogon and LogonUI use. An unreadable index denies (fail closed).

Sub-authentication filter (subauth.cpp):
- Authoritative and UserFlags are set on every path; the docs call them
  [out] and want Authoritative valid regardless of the result.
- The veto is STATUS_INVALID_WORKSTATION, which the docs list;
  STATUS_ACCOUNT_RESTRICTION is not in that list.
- MSV1_0_PASSTHRU logons get no objection: the index only knows local RIDs.
- LogoffTime/KickoffTime stay untouched, and every call logs the incoming
  values to the debugger, so the VM test can settle what MSV1_0 passes.

Store:
- The index reads that run in lsass moved to store_index.cpp. The two LSA
  DLLs link only that file plus advapi32: no DPAPI, TOTP or bcrypt code in
  lsass anymore. Checked on the object files: the only unresolved symbols
  are Windows imports, __C_specific_handler and memset.
- Buf and the SEH exception filter are shared in lsasafe.h.
- Name normalization folds case with LCMapStringW(LOCALE_INVARIANT), so
  non-ASCII names match the name index; towlower only folded ASCII.
- QueryAnyEnrollment counts REG_BINARY values under SID names only.
- The DACL is set when a key is created; repairing old keys is the new
  EnsureStoreAcls, called by enroll.exe, not by every SaveState on the
  logon path.

Build: /CETCOMPAT on every binary.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Q8WTBzhJ9rZXAXLe89ssH
…<SID>

- UpdateRemoteCredential leaves an RDP credential with its own provider
  while nothing is enrolled (Filter() hides nothing then), and leaves
  non-password logons such as smartcard alone instead of taking and then
  refusing them.
- enroll /list shows every stored secret, its account and both index
  states, and marks secrets of deleted accounts, which still keep the
  filter active. enroll /remove accepts the SID from /list; it is
  canonicalized, so a lower-case SID still finds its entries.
- enroll re-applies the store ACL once per run (EnsureStoreAcls), after
  argument checking.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Q8WTBzhJ9rZXAXLe89ssH
- Besides RunAsPPL/RunAsPPLBoot, both scripts look for WinInit event 12
  ("started as a protected process") since the last boot. Windows 11 22H2+
  turns LSA protection on by default on new, enterprise-joined HVCI
  devices, and that is not documented to show up in RunAsPPL.
- install-subauth.ps1 refuses on a domain controller, where the filter
  would check domain RIDs against the local index.
- The texts say that MSV1_0 loads the filter on first use, so a broken one
  can surface at the first network logon, not only at boot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Q8WTBzhJ9rZXAXLe89ssH
- tests/native/test_lsa compiles the real subauth.cpp, ap.cpp and
  store_index.cpp against a fake registry, event log and LSA heap, under
  ASan and UBSan: every veto and fail-closed path, the [out] parameters,
  hostile logon buffers (overruns, odd lengths, packed offsets, embedded
  NULs, overlong names), non-ASCII folding, and no leaked LSA heap block or
  registry handle. Re-introducing six of the old defects one at a time
  makes it fail each time.
- tests/totp_check.py exits non-zero on a failed check and counts the
  Base32 checks; before, CI could not see a failure.
- CI parses every PowerShell script, and runs /analyze /WX over the lsass
  code, so a finding there fails the build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Q8WTBzhJ9rZXAXLe89ssH
Brings the README in line with the previous commits: install-doppio.ps1
and -EnableFilter, enroll /list and /remove <SID>, the status codes and
fail-closed rules of both LSA pieces, the open NetworkCleartext question,
the runtime LSA-protection check, events 200-210 and test_lsa.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Q8WTBzhJ9rZXAXLe89ssH
The README told readers to keep the deny rights for NetworkCleartext
logons as if they were known to cover type 8. That is not documented, so
it now says to check it in the VM, like the filter's behaviour for those
logons.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Q8WTBzhJ9rZXAXLe89ssH
@Nobrac
Nobrac merged commit 37a827d into main Oct 11, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants