Foreword
This is the third part, and it's not the one I announced at the end of Part 2.
Back there I wrote that a Part 3 might take on the SSP/AP, done right: signed, PPL-compatible, working with Credential Guard instead of poking at it. That one is still on the list. This article exists because of a complaint I kept getting about Part 2, and one of the people complaining was me.
The sub-authentication filter from Part 2 refuses every network logon for an enrolled account. That's correct, and it's what I asked it to do. It also means an enrolled account can't map a share anymore, can't log on over WinRM, and can't log on over RDP with NLA. Safe, yes, but you can't use the account over the network for anything.
The question for this part: can an account have 2FA and reach \\host\share? A code per connection is impossible, the next chapter explains why. A gate works well enough to be worth building.
I called the tool Ristretto. A ristretto is a short shot, the same coffee with less water, and the tool is the same second factor with a shorter window. It lives in its own repository next to Doppio. It needs Doppio installed and enrolled, it reads Doppio's RID index read-only, and it links Doppio's verify.cpp unmodified.
warning
Same ground rules as Part 2. Throwaway VM, a snapshot before every reboot, RunAsPPL off, the BitLocker recovery key within reach, and a practised way to edit the registry offline from WinRE. The filter in this article takes over the Auth0 slot from Doppio's TacSubAuth, so you swap one unsigned DLL in lsass for another.
As in both earlier parts, all the code is in the article. It compiles for x64, and I'll be explicit about what I have run and what I haven't.
note
Changelog, October 2026. I changed this article twice after it first went up. The details are next to the code.
- The second review of Part 2. Ristretto follows it now. The veto is
STATUS_INVALID_WORKSTATION, the filter sets all its out-parameters, leaves pass-through logons alone, and calls Doppio's enrollment read instead of a copy. The repo also has a test for the filter. - A review of this part. A second run of the installer lost the record of the filter it replaced, so uninstalling never brought
TacSubAuthback. That's fixed, and the installer checks LSA protection the way Doppio's does. Closing a gate stops new logons, not sessions that are already up, and the tool says so now. The gate tool and the text got a few smaller fixes.
Prerequisites
- Part 2, installed and working. Or at least Doppio from the repo, with the credential provider installed and an account enrolled.
enroll /reindexrun at least once, so the RID index underHKLM\SOFTWARE\TheAdminCafe\2FA\Ridsexists. Ristretto reads that index and won't install without it.- A Windows 10/11 or Server VM that you can delete, with a snapshot. Before every reboot, not once at the beginning.
- Visual Studio 2026 or 2022 (Community is enough) or the Build Tools, with "Desktop development with C++".
RunAsPPLoff on the test VM, same as in Part 2.- Git, because Doppio comes in as a submodule.
- A kernel debugger, if you can set one up. It's the only way to see what the filter does inside lsass.
Why there is no second factor per SMB connection
You'd want the server to ask for a code when a connection comes in, the way the tile asks at the console. NTLM has no place for that question.
NTLMv2 is challenge/response. The server sends a challenge, the client computes a response from the password hash, and the server checks it against the SAM. The password never crosses the wire, and neither does anything else the user typed. There is no round trip in that handshake where the server can say "and now a six-digit code", and no client that would know what to do with it. Microsoft finished the protocol long before anybody wanted a second factor in it.
The sub-authentication filter sits further away from a UI. By the time MSV1_0 calls it, the password is already checked and the only thing left is yes or no. It gets a SAM record and a logon level. It can't prompt or wait, and anything slow it does on the logon path inside lsass.
The second factor has to happen somewhere else and at some other time, and the network logon has to look up the result later. In Ristretto that result is a timestamp in the registry, and I call it the gate.
The gate pattern
You prove the second factor once, out of band, and that buys a time window in which the filter lets network logons for that account through. Duo calls the same idea Remembered Devices. Ristretto does it without a cloud:
ristretto.exe aliceasks for a one-time code and checks it through Doppio'sverify.cpp.- On success it writes a unix timestamp to
HKLM\SOFTWARE\TheAdminCafe\Ristretto\Gate\<rid>. That key has the same SYSTEM-and-Administrators ACL that Doppio uses for its store. - On every network logon for an enrolled account, the filter reads that one value. Fresh enough: no objection. Stale or missing: veto, the same answer
TacSubAuthgives.
The window is 300 seconds by default, and the filter clamps it to 30 to 3600 seconds. ristretto /close alice shuts it right away, /closeall drops every open gate, and /mode off is a kill switch that takes effect within about five seconds, without a reboot.
The window covers the whole account, every connection to it. While it's open, anyone who can authenticate as that account gets in: your second laptop, a colleague who knows the password, an NTLM relay that catches the right moment. The gate proves that somebody with the phone was at this machine a few minutes ago. It doesn't prove that the person on the other end of this particular SMB connection is that somebody.
The window also only covers new logons. The filter runs when a logon is made, not on every file you open. A share you map inside the window stays up after the window ends, and after /close too, until it disconnects. The same goes for a WinRM session. Close-SmbSession on the protected machine ends SMB sessions.
You get an enrolled account that can still map a share, and you pay for it with a window in which nobody asks for the second factor. Five minutes of password-only is a lot better than permanent password-only, which is what you had before enrolling, and a lot weaker than the flat deny in Part 2. Pick the one that matches what you protect.
What lives where
Everything Ristretto reads or writes, in one place:
HKLM\SOFTWARE\TheAdminCafe\2FA\Rids (Doppio, read-only for us)
<rid> presence marker per enrolled account
HKLM\SOFTWARE\TheAdminCafe\Ristretto (SYSTEM + Administrators)
Mode REG_DWORD 1 = gate enforced, 0 = kill switch
WindowSec REG_DWORD gate lifetime, clamped 30-3600
PrevAuth0 REG_SZ what Auth0 was before us, or __none__
HKLM\SOFTWARE\TheAdminCafe\Ristretto\Gate (SYSTEM + Administrators)
<rid> REG_QWORD unix time of the last successful unlock
HKLM\SYSTEM\CurrentControlSet\Control\Lsa\MSV1_0
Auth0 REG_SZ RistrettoFilter
C:\Program Files\TheAdminCafe\
TacProvider.dll, enroll.exe (Doppio)
ristretto.exe (must sit here, see below)
state.lock (Doppio's StateLock, shared)
C:\Windows\System32\RistrettoFilter.dll
And the event IDs, all under the source "TheAdminCafe Ristretto" in the Application log:
| ID | Written by | Meaning |
|---|---|---|
| 300 | ristretto.exe | gate opened |
| 301 | filter | network logon refused |
| 302 | ristretto.exe | gate closed, or all gates closed |
| 303 | filter | exception in the decision, refused (fail closed) |
| 304 | ristretto.exe | enforcement switched on or off |
301 and 303 come from inside lsass, and writing to the event log from there is an RPC to the eventlog service. The filter has that switched off by default, so you see those two in the kernel debugger only. Without the debugger you see a refusal as a failed 4625 in the Security log, logon type 3. MSV1_0 reports STATUS_INVALID_WORKSTATION, the code the filter returns, as the sub-status of an account restriction. So expect Sub Status 0xC0000070, most likely under Status 0xC000006E. I haven't seen that 4625 yet, so look at both fields. That needs failure auditing for logons, auditpol /get /subcategory:Logon shows whether it's on.
Architecture
Two binaries, and only one of them runs in lsass:
RistrettoFilter.dll | ristretto.exe | |
|---|---|---|
| Source | src/ristretto_filter.cpp | src/ristretto.cpp |
| Runs in | lsass.exe | an elevated console |
| Doppio code linked | store_index.cpp | totp.cpp, store.cpp, store_index.cpp, verify.cpp, statelock.cpp |
| Reads | 2FA\Rids, Ristretto, Ristretto\Gate | Doppio's store and state, Ristretto |
| Writes | nothing | Ristretto\Gate, Ristretto config, Doppio's state |
| Heap, CRT | no | yes |
| A bug costs you | the machine | one gate |
Everything that needs DPAPI, account lookups, std::wstring or a console sits in the exe, where a crash kills a tool. The DLL does the smallest possible thing: two registry reads and a comparison.
Who reads and writes what, as a picture:
Four questions, in that order, on every logon MSV1_0 hands the filter. Three of them exit to "no objection" and cost at most one registry read. Only an enrolled account on a network logon to this machine ever reaches the gate check. The veto is STATUS_INVALID_WORKSTATION, the same code Doppio's filter returns, so MSV1_0 sees the same refusal as with Doppio's filter. Only the condition in front of it changed. My first version returned STATUS_ACCOUNT_RESTRICTION, like Doppio did back then. The second review of Part 2 found that this code isn't on Microsoft's list for this function, and both filters changed.
The filter
ristretto_filter.cpp is one file, and all of it is in this chapter, in the order it sits in the repo, with the explanation between the pieces.
Header and includes
// RistrettoFilter - standalone MSV1_0 sub-authentication FILTER (Auth0).
//
// Companion to Doppio (github.com/Nobrac/Doppio), not a patch. It reads
// Doppio's RID enrollment index (HKLM\SOFTWARE\TheAdminCafe\2FA\Rids) and
// nothing else of Doppio's. While installed it takes over the Auth0 slot
// from TacSubAuth (the installer saves and restores it).
//
// Policy: a NETWORK logon for an enrolled local account needs a fresh gate
// timestamp (HKLM\SOFTWARE\TheAdminCafe\Ristretto\Gate\<rid>, written by
// ristretto.exe after a TOTP check). No fresh gate -> veto, which is exactly
// what TacSubAuth would have done anyway. Everything else: no objection.
//
// Same rules as Doppio's filter: runs in lsass, no heap, fixed buffers, one
// registry read per decision, no DPAPI, no LSA lookups, never touches
// credential material. Test-VM code: unsigned, needs RunAsPPL off.
//
// The RID-index read is Doppio's own tac::EnrollmentByRid, from
// store_index.cpp, linked into this DLL. That file holds nothing but the
// index reads: no DPAPI, no account lookups, no heap. Earlier versions of
// this filter copied the read by hand, because it used to live in store.cpp
// next to CryptUnprotectData and LookupAccountName/Sid, which must not go
// into lsass. Doppio split the file (commit 37a827d), so the copy is gone and
// the two can no longer drift apart; see SECURITY-FIXES.md, item 14.
//
// The out-parameters and the veto code follow the documentation of
// Msv1_0SubAuthenticationFilter, like Doppio's filter: see item 15.
//
// Registered as:
// HKLM\SYSTEM\CurrentControlSet\Control\Lsa\MSV1_0
// Auth0 (REG_SZ) = RistrettoFilter
//
// ---------------------------------------------------------------------
// Hardening notes for anything that runs inside lsass (see also
// SECURITY-FIXES.md):
//
// * No CRT on the decision path. swprintf_s() calls the invalid-parameter
// handler on overflow, and the default handler terminates the process.
// In lsass that is a bugcheck and a reboot loop. Everything here uses
// bounded append helpers that truncate instead.
// * SEH (__try/__except), not C++ try/catch. With /EHsc a catch(...) does
// NOT catch access violations, so the original catch block could never
// have fired on the failure that actually matters.
// * The whole decision runs behind one __except that fails closed.
// * Time comes from GetSystemTimeAsFileTime, not _time64: same value
// (UTC unix seconds), no CRT, no locale, no heap.
// ---------------------------------------------------------------------
#define SECURITY_WIN32
#include <windows.h>
#include <sspi.h>
#include <ntsecapi.h>
#include <subauth.h>
#include "store.h" // Doppio: tac::EnrollmentByRid, from store_index.cpp
#pragma comment(lib, "advapi32.lib")
#ifndef STATUS_SUCCESS
#define STATUS_SUCCESS ((NTSTATUS)0x00000000L)
#endif
// The documentation of Msv1_0SubAuthenticationFilter lists the values the
// function "must return". STATUS_ACCOUNT_RESTRICTION, which this filter used
// to veto with, is not among them; STATUS_INVALID_WORKSTATION is, and it says
// what we mean: this account may not log on over this path right now.
#ifndef STATUS_INVALID_WORKSTATION
#define STATUS_INVALID_WORKSTATION ((NTSTATUS)0xC0000070L)
#endif
#ifndef MSV1_0_PASSTHRU
#define MSV1_0_PASSTHRU 0x01
#endif
// Writing to the event log from lsass is an RPC to the eventlog service;
// the conservative default on the logon path is off. The veto is always
// observable as a failed 4625 in the Security log either way.
#define RISTRETTO_FILTER_EVENTLOG 0
// Log every *allowed* logon too. Off by default: it puts account names in
// front of anything holding a debugger on the box, on every SMB connection.
#define RISTRETTO_FILTER_VERBOSE 0
The header comment is the contract, and the rest of the file has to keep it. Three things in there are worth reading twice:
- The enrollment check is Doppio's. The filter links one file of Doppio's,
store_index.cpp, and callstac::EnrollmentByRid. My first version copied fifteen lines instead. Back then the read sat instore.cpp, next toCryptUnprotectDataandLookupAccountName/LookupAccountSid. DPAPI and account lookups call back into LSA, and calling back into LSA from inside an LSA callback is how you get a deadlock on the logon path. The second review of Part 2 moved the index reads into their own file, without DPAPI, without account lookups and without a heap. So the copy is gone, and the two can't drift apart anymore. - Each hardening note is a finding from the audit later in this article. I put them at the top so the next person who edits the file reads them before they touch the code.
RISTRETTO_FILTER_EVENTLOGandRISTRETTO_FILTER_VERBOSEare both off. The event log is an RPC from inside lsass, and verbose logging puts an account name intoOutputDebugStringWon every SMB connection, readable by anything that holds a debugger on the box. A veto logs to the debugger, an allow doesn't.
The #ifndef guards around the two STATUS_ defines are there because ntstatus.h and windows.h fight over them. Including ntstatus.h cleanly needs WIN32_NO_STATUS games that aren't worth it for two constants. MSV1_0_PASSTHRU gets the same guard, in case a header already defines it. The export further down uses it.
Constants
namespace
{
const wchar_t kGatePath[] = L"SOFTWARE\\TheAdminCafe\\Ristretto\\Gate";
const wchar_t kConfigPath[] = L"SOFTWARE\\TheAdminCafe\\Ristretto";
// Window bounds. The registry key is SYSTEM/Administrators only, so this
// is not a privilege boundary - it is a guard against a typo turning the
// gate into a permanently open door. 0xFFFFFFFF seconds is 136 years.
const DWORD kWindowDefault = 300; // 5 min
const DWORD kWindowMin = 30;
const DWORD kWindowMax = 3600; // 1 h
const DWORD kConfigRefreshMs = 5000;
Both paths are relative to HKEY_LOCAL_MACHINE, and so is Doppio's 2FA\Rids, which comes from storekeys.h now. The filter never opens HKEY_CURRENT_USER or anything per-user, it runs as SYSTEM inside lsass and has no business there.
The key is admin-only, and an admin can switch the filter off, so the window bounds don't stop an attacker. They stop a typo from turning the gate into a door that stays open for 136 years. More about that in the audit chapter.
A string builder without the CRT
// ---- bounded, CRT-free string building ---------------------------
class Buf
{
public:
Buf(WCHAR* p, SIZE_T cap) : p_(p), cap_(cap), n_(0)
{
if (cap_) p_[0] = L'\0';
}
void Str(const WCHAR* s)
{
if (!s) return;
while (*s && n_ + 1 < cap_) p_[n_++] = *s++;
if (cap_) p_[n_] = L'\0';
}
void U64(ULONGLONG v)
{
WCHAR t[21];
int i = 0;
do { t[i++] = (WCHAR)(L'0' + (ULONG)(v % 10)); v /= 10; } while (v && i < 20);
while (i > 0 && n_ + 1 < cap_) p_[n_++] = t[--i];
if (cap_) p_[n_] = L'\0';
}
SIZE_T Len() const { return n_; }
private:
WCHAR* p_;
SIZE_T cap_;
SIZE_T n_;
};
Buf is the replacement for swprintf_s on the lsass path. It has two rules:
- It never writes past
cap_.n_ + 1 < cap_keeps one slot for the terminator, and every append writes the terminator again, so the buffer is a valid string after every call. - It truncates when the buffer is full. A log line that ends in the middle of a word is a cosmetic problem.
swprintf_son overflow calls the invalid-parameter handler, and that ends the process, which here is lsass.
U64 writes the digits backwards into a small stack array and then copies them in the right order. t[21] is enough for the 20 digits of the largest ULONGLONG, and the function needs neither _ultow nor the locale or the heap.
The RID as a value name
// RID as a decimal string, same convention as Doppio's 2FA\Rids values.
//
// Written out longhand rather than through Buf on purpose. Buf terminates
// correctly, but /analyze cannot see that across the call and reports
// C6054 ("might not be zero-terminated") at every use of the result. With
// /WX that ends the build, and silencing a not-terminated warning in code
// that runs inside lsass is the wrong habit to get into - so the
// termination is made obvious here instead.
//
// A ULONG is at most 10 digits (4294967295), so out[12] always fits the
// digits plus the terminator.
void RidToValueName(ULONG rid, WCHAR (&out)[12])
{
WCHAR digits[10];
int n = 0;
do
{
digits[n++] = (WCHAR)(L'0' + (ULONG)(rid % 10));
rid /= 10;
} while (rid != 0 && n < 10);
int i = 0;
while (n > 0) out[i++] = digits[--n];
out[i] = L'\0'; // i <= 10, so this is in bounds
}
Doppio writes its index values as the decimal RID, 1001 for the first local user after the built-in ones. The filter has to build the same string.
Buf would do the job, but /analyze objects. Buf terminates its string, but the analyzer can't see that across the call and reports C6054, "string might not be zero-terminated", at every place that uses the result. With /WX that's a broken build. The easy fix would be to suppress the warning, and in code that runs inside lsass that's a habit I don't want. So the function does it longhand, and the termination is visible in the function itself.
WCHAR (&out)[12] is a reference to an array of 12 characters. The compiler refuses any other buffer at the call site, which is a bounds check you get for free.
Time without the CRT
// ---- time --------------------------------------------------------
// Identical to _time64(nullptr): UTC seconds since the unix epoch.
ULONGLONG NowUnix()
{
FILETIME ft;
GetSystemTimeAsFileTime(&ft); // UTC, no CRT
ULARGE_INTEGER u;
u.LowPart = ft.dwLowDateTime;
u.HighPart = ft.dwHighDateTime;
// FILETIME counts 100 ns ticks from 1601-01-01.
const ULONGLONG kTicksToEpoch = 116444736000000000ULL;
if (u.QuadPart < kTicksToEpoch) return 0;
return (u.QuadPart - kTicksToEpoch) / 10000000ULL;
}
FILETIME counts 100-nanosecond ticks since 1 January 1601. 116444736000000000 is the number of those ticks between 1601 and 1970, so subtracting it and dividing by ten million gives unix seconds. That's the value _time64(nullptr) returns, without the CRT. ristretto.exe uses the identical function, so both sides agree on what "now" means.
A system clock before 1970 returns 0. You won't see that on a real machine, but an unsigned subtraction that wraps around would produce a gate timestamp in the far future, so the guard costs one comparison and closes the case.
The config cache
// ---- config cache ------------------------------------------------
// Several logon threads run this concurrently. Aligned 32/64-bit loads
// are atomic on x64, and the refresh slot is claimed with an interlocked
// compare-exchange so only one thread does the registry read.
volatile LONG64 g_nextCfgTick = 0;
volatile LONG g_windowSec = (LONG)kWindowDefault;
volatile LONG g_enforce = 1; // fail closed until told otherwise
void ReadConfigThrottled()
{
const LONG64 now = (LONG64)GetTickCount64();
const LONG64 next = InterlockedCompareExchange64(&g_nextCfgTick, 0, 0);
if (now < next) return;
// Loser of the race keeps using the cached values - never blocks.
if (InterlockedCompareExchange64(&g_nextCfgTick, now + kConfigRefreshMs, next) != next)
return;
DWORD v = 0, cb = sizeof(v);
if (RegGetValueW(HKEY_LOCAL_MACHINE, kConfigPath, L"WindowSec",
RRF_RT_REG_DWORD, nullptr, &v, &cb) == ERROR_SUCCESS)
{
if (v < kWindowMin) v = kWindowMin;
if (v > kWindowMax) v = kWindowMax;
InterlockedExchange(&g_windowSec, (LONG)v);
}
cb = sizeof(v);
if (RegGetValueW(HKEY_LOCAL_MACHINE, kConfigPath, L"Mode",
RRF_RT_REG_DWORD, nullptr, &v, &cb) == ERROR_SUCCESS)
InterlockedExchange(&g_enforce, v ? 1 : 0);
// No value / unreadable -> keep the previous (enforcing) state.
}
This runs on every network logon, from several lsass threads at the same time. Reading the registry on every logon would work, but it's two more registry calls on a hot path for a value that changes maybe once a month.
The filter caches the config and refreshes it at most every five seconds:
g_nextCfgTickholds the tick count when the next refresh is due.InterlockedCompareExchange64(&x, 0, 0)is the usual trick for an atomic read: compare with 0, and if equal write 0, which changes nothing.- The thread that sees the refresh is due tries to claim it with a compare-exchange from the old value to the new deadline. One thread wins. Every other thread returns and uses the cached values. Nobody waits, which matters on a logon path.
- The filter clamps
WindowSecwhen it reads it. A value of 0 becomes 30,0xFFFFFFFFbecomes 3600. g_enforcestarts at 1. IfModeis missing or unreadable, the filter keeps what it had, and on a fresh lsass that's "enforce". You have to set the kill switch on purpose.
On x64 an aligned 32-bit load is atomic, so the first version without the interlocked calls didn't have an exploitable race. The audit flagged it as incorrect, and the interlocked calls fix that.
Enrollment in three outcomes
// ---- enrollment ----------------------------------------------------
// tac::EnrollmentByRid (Doppio, store_index.cpp) has the three outcomes
// this filter needs, with exactly the semantics the old hand copy had:
//
// Rids key absent -> No Doppio is not installed: allow
// value absent -> No genuinely not enrolled: allow
// any other read error -> Unknown refuse this one account
//
// Keeping "key absent" permissive is deliberate (SECURITY-FIXES.md, item
// 3): failing closed there would deny every network logon on the machine
// the moment Doppio is uninstalled.
Only a comment is left here. The function behind it changed most during the audit. The original IsEnrolledByRid returns a bool, so every failure becomes "not enrolled". In a filter whose whole job is the veto, "not enrolled" means STATUS_SUCCESS. One unexpected registry error and an enrolled account walks past the gate.
My fix was a function EnrollmentOf in this file, with three results instead of two. Doppio's own filter had the same hole, and after its second review Doppio has the same three results in tac::EnrollmentByRid. The code is in Part 2, in store_index.cpp. Ristretto calls it, and EnrollmentOf is gone:
| What the registry says | tac::Enrollment | What the filter does |
|---|---|---|
Rids key missing | No | allow, Doppio isn't installed, nothing of ours to enforce |
| value for this RID missing | No | allow, not enrolled |
| anything else that fails | Unknown | refuse that one account |
The first row is deliberate and the only one I'm still a bit unhappy with. Failing closed there would deny every network logon on the machine the moment somebody uninstalls Doppio. People rip out a security tool that bricks file sharing when you remove it. So a missing index allows the logon, and a broken read refuses it.
Doppio's QueryIndex calls RegQueryValueExW with all pointers nullptr. That only asks "does this value exist". It doesn't read the data, because the value is a presence marker and nothing more.
Is the gate fresh?
// ---- gate ----------------------------------------------------------
bool GateFresh(ULONG rid, ULONG& ageSec)
{
ageSec = 0;
WCHAR value[12] = {};
RidToValueName(rid, value);
ULONGLONG ts = 0;
DWORD cb = sizeof(ts);
if (RegGetValueW(HKEY_LOCAL_MACHINE, kGatePath, value,
RRF_RT_REG_QWORD, nullptr, &ts, &cb) != ERROR_SUCCESS)
return false; // missing or unreadable -> closed
const ULONGLONG now = NowUnix();
if (now < ts) return false; // clock moved back -> locked
const ULONGLONG age = now - ts;
ageSec = (ULONG)(age > 0xFFFFFFFFull ? 0xFFFFFFFFull : age);
const ULONG window = (ULONG)InterlockedCompareExchange(&g_windowSec, 0, 0);
return age <= (ULONGLONG)window;
}
One registry read, RRF_RT_REG_QWORD so a value of the wrong type fails instead of being misread. Then three cases:
- Missing or unreadable: closed.
- Timestamp in the future: closed. The clock went backwards since you opened the gate, by NTP, by hand or by a snapshot restore, and the filter treats that skew as closed.
- Otherwise: open if
age <= window.
ageSec is for the log line, clamped to 32 bits so a gate from last year prints a number too.
Logging
// SAM name, for the log only. The decision runs on the RID alone.
void SamName(const USER_ALL_INFORMATION* ua, WCHAR (&out)[129])
{
out[0] = L'?';
out[1] = L'\0';
if (!ua) return;
const UNICODE_STRING& us = ua->UserName;
if (!us.Buffer || us.Length == 0 || (us.Length % sizeof(WCHAR)) != 0)
return;
SIZE_T cch = us.Length / sizeof(WCHAR);
if (cch > 128) cch = 128;
for (SIZE_T i = 0; i < cch; ++i)
{
WCHAR c = us.Buffer[i];
if (c == L'\0') { cch = i; break; }
// Keep control characters out of the log line.
out[i] = (c < 0x20) ? L'.' : c;
}
out[cch] = L'\0';
}
void Debug(const WCHAR* text)
{
WCHAR line[384];
Buf b(line, ARRAYSIZE(line));
b.Str(L"[Ristretto] ");
b.Str(text);
b.Str(L"\n");
OutputDebugStringW(line);
}
void Report(WORD type, DWORD id, const WCHAR* text)
{
Debug(text);
#if RISTRETTO_FILTER_EVENTLOG
HANDLE h = RegisterEventSourceW(nullptr, L"TheAdminCafe Ristretto");
if (h)
{
LPCWSTR s[1] = { text };
ReportEventW(h, type, 0, id, nullptr, 1, 0, s, nullptr);
DeregisterEventSource(h);
}
#else
(void)type; (void)id;
#endif
}
void ReportVeto(ULONG rid, const WCHAR* name, ULONG ageSec, const WCHAR* why)
{
WCHAR text[320];
Buf b(text, ARRAYSIZE(text));
b.Str(L"Refused a network logon for enrolled account '");
b.Str(name);
b.Str(L"' (RID ");
b.U64(rid);
b.Str(L"): ");
b.Str(why);
b.Str(L" (last unlock ");
b.U64(ageSec);
b.Str(L" s ago or none).");
Report(EVENTLOG_WARNING_TYPE, 301, text);
}
SamName copies the account name out of USER_ALL_INFORMATION for the log only. The decision runs on the RID, never on the name. The function is careful because the UNICODE_STRING comes from the caller and is the one pointer in this file we dereference:
Lengthcounts bytes, and it doesn't include a terminator. An odd byte count is corrupt, so the function gives up and logs?.- It stops at 128 characters and at an embedded
NUL. - Control characters become
., so a crafted name can't put a line break into a log line.
Debug adds the [Ristretto] prefix. That prefix is what you filter for in the kernel debugger. Report sends the same text to the event log if you compile with RISTRETTO_FILTER_EVENTLOG 1.
ReportVeto builds the line that the first draft built with swprintf_s. Same text, now in a 320-character buffer with Buf. If somebody adds three words to it next year, the line gets cut at the end and lsass keeps running.
The exception filter
// Which exceptions the filter swallows.
//
// A constant EXCEPTION_EXECUTE_HANDLER would catch everything, and
// /analyze is right to flag that (C6320): some exceptions must not be
// handled. A stack overflow is the clear case - the guard page is gone by
// the time the filter runs, so continuing on that thread is worse than
// letting it go. Everything else here means a bad pointer from the
// caller, and on that one the bugcheck is the worse outcome, so it is
// caught and the logon refused.
LONG DecisionFilter(DWORD code)
{
switch (code)
{
case EXCEPTION_STACK_OVERFLOW:
case EXCEPTION_NONCONTINUABLE_EXCEPTION:
return EXCEPTION_CONTINUE_SEARCH; // not ours to survive
default:
return EXCEPTION_EXECUTE_HANDLER;
}
}
DecisionFilter decides which exceptions the __except further down may swallow. My first fix used a plain EXCEPTION_EXECUTE_HANDLER, which catches everything. /analyze flagged that as C6320, and it's right.
A stack overflow is the clear case. By the time the handler runs, the guard page is gone, and carrying on in that thread is worse than letting the exception go. A non-continuable exception is the same idea. Everything else that can happen in this file is a bad pointer from the caller, an access violation. For that one the alternative is a dead lsass, so the filter catches it and refuses the logon.
The decision
// The actual decision. Split out so the __try frame in the exported
// function holds nothing but PODs - __try cannot coexist with objects
// that need unwinding in the same frame.
NTSTATUS Decide(PUSER_ALL_INFORMATION UserAll)
{
if (!UserAll)
return STATUS_SUCCESS; // nothing to judge
ReadConfigThrottled();
if (!InterlockedCompareExchange(&g_enforce, 0, 0))
return STATUS_SUCCESS; // kill switch
const ULONG rid = UserAll->UserId;
WCHAR name[129];
SamName(UserAll, name);
switch (tac::EnrollmentByRid(rid))
{
case tac::Enrollment::No:
return STATUS_SUCCESS; // not enrolled: not our business
case tac::Enrollment::Unknown:
// The index is there but this read failed. Do not guess "not
// enrolled" - that is the one wrong answer that opens the gate.
ReportVeto(rid, name, 0, L"the enrollment index could not be read");
return STATUS_INVALID_WORKSTATION;
case tac::Enrollment::Yes:
default:
break;
}
ULONG age = 0;
if (GateFresh(rid, age))
{
#if RISTRETTO_FILTER_VERBOSE
WCHAR ok[256];
Buf b(ok, ARRAYSIZE(ok));
b.Str(L"Gate fresh for '");
b.Str(name);
b.Str(L"' (RID ");
b.U64(rid);
b.Str(L", ");
b.U64(age);
b.Str(L" s old). No objection.");
Debug(ok);
#endif
return STATUS_SUCCESS;
}
ReportVeto(rid, name, age, L"no fresh gate");
return STATUS_INVALID_WORKSTATION;
}
}
This is the diagram in code. The order matters for cost: the kill switch and the enrollment check exit early, and only an enrolled account pays for the gate read.
Decide copies the name before the switch on purpose. ReportVeto needs it for both refusal paths, and copying it once keeps the switch short.
Decide is its own function because of __try. MSVC doesn't allow __try in a function that has objects with destructors in the same frame (error C2712). Decide is plain code with only PODs, but splitting it out keeps the rule obvious: the export is the wrapper, and nothing in the wrapper needs unwinding.
The export
// The documentation calls WhichFields, UserFlags, Authoritative, LogoffTime
// and KickoffTime [out]. The three we can answer for are written on every
// path, before anything can return:
//
// * WhichFields = 0: nothing to write back to the SAM.
// * UserFlags = 0: we claim neither LOGON_GUEST nor LOGON_NOENCRYPTION.
// * Authoritative = TRUE: the documentation says it "should return valid
// information regardless of the return value". Earlier versions left it
// as MSV1_0 passed it in unless they refused. For a local account there
// is no other domain controller to ask, so our answer is final either
// way.
//
// LogoffTime and KickoffTime stay untouched: they carry the account's logon
// hours, and a gate has no business extending them. Doppio's filter logs the
// incoming values for the VM test; this one stays quiet on allowed logons.
NTSTATUS NTAPI Msv1_0SubAuthenticationFilter(
NETLOGON_LOGON_INFO_CLASS LogonLevel,
PVOID /*LogonInformation*/, // challenge/response - never read
ULONG Flags,
PUSER_ALL_INFORMATION UserAll, // hashes live here too - never read
PULONG WhichFields,
PULONG UserFlags,
PBOOLEAN Authoritative,
PLARGE_INTEGER /*LogoffTime*/, // account logon hours - not ours
PLARGE_INTEGER /*KickoffTime*/)
{
if (WhichFields)
*WhichFields = 0;
if (UserFlags)
*UserFlags = 0;
if (Authoritative)
*Authoritative = TRUE;
const bool isNetwork = (LogonLevel == NetlogonNetworkInformation ||
LogonLevel == NetlogonNetworkTransitiveInformation);
// MSV1_0_PASSTHRU: "the user is not connecting to this machine". The
// enrollment index only knows the RIDs of local accounts, and a RID from
// another machine can be the same number as a local one, so a pass-through
// logon is not ours to gate.
const bool passthru = (Flags & MSV1_0_PASSTHRU) != 0;
// Anything that is not a network logon to this machine is out of scope,
// including the interactive path Doppio's credential provider already
// owns.
if (!isNetwork || passthru)
return STATUS_SUCCESS;
NTSTATUS result;
// An access violation here would take lsass down with it, which is a
// bugcheck and a reboot loop on the next boot. Catching it and refusing
// is the lesser evil on the one path this filter exists for. Nothing
// below allocates, so there is no state to unwind.
__try
{
result = Decide(UserAll);
}
__except (DecisionFilter(GetExceptionCode()))
{
Report(EVENTLOG_ERROR_TYPE, 303,
L"Unhandled exception while deciding a network logon - refusing (fail closed).");
result = STATUS_INVALID_WORKSTATION;
}
return result;
}
BOOL APIENTRY DllMain(HMODULE h, DWORD reason, LPVOID)
{
if (reason == DLL_PROCESS_ATTACH)
DisableThreadLibraryCalls(h);
return TRUE;
}
Same signature as Doppio's filter in Part 2, and the same handling of the out-parameters. The filter sets three of them first, before anything can return:
*WhichFields = 0: we don't ask MSV1_0 to write any account field back.*UserFlags = 0: the filter claims neitherLOGON_GUESTnorLOGON_NOENCRYPTION. My first version didn't touch it.*Authoritative = TRUE, on a refusal and on "no objection". My first version set it only on a refusal and left it as MSV1_0 passed it in otherwise. The documentation says the filter "should return valid information regardless of the return value".FALSEmeans "the logon request can be tried again on another domain controller", and a local account has no other domain controller.LogoffTimeandKickoffTimestay as MSV1_0 calculated them. They carry the logon hours, and a veto has no business extending a session.LogonInformationdoesn't get a parameter name. For a network logon it points at the challenge and the response, andUserAllhas the password hashes right next to the RID. The filter reads neither.
NetlogonNetworkTransitiveInformation is the pass-through variant, a network logon that a domain controller forwards for a trusted domain. On a workstation with local accounts you won't see it, but if the filter ever ends up on a DC, the filter should gate a transitive network logon like a direct one.
Flags matters for one bit. With MSV1_0_PASSTHRU set, the documentation says "the user is not connecting to this machine". Doppio's index only knows the RIDs of local accounts, and a RID from another machine can be the same number as a local one. My first version ignored Flags and would have checked such a logon against the local account with that RID. Now a pass-through logon gets no objection, the same as in Doppio's filter.
Everything that isn't a network logon returns before the __try. Interactive logons belong to Doppio's tile, and batch and service logons are what the deny rights from Part 2 are for.
RistrettoFilter.def exports one function:
LIBRARY RistrettoFilter
EXPORTS
Msv1_0SubAuthenticationFilter
The gate tool
ristretto.exe is the other half. It runs elevated in a console, so it can use the heap, std::wstring, DPAPI and everything else the filter can't. This chapter has the whole file again, in order.
Includes and the Doppio API
// ristretto.exe - opens the network gate for an enrolled local account.
//
// Verifies the TOTP through Doppio's own verify.cpp (linked UNMODIFIED), so
// lockout, replay protection and the shared state file behave exactly like at
// the logon tile:
// - one failure counter for tile and gate (5 free, then doubling locks
// 5 -> 10 -> 20 -> 40 -> 60 min, capped)
// - a code burned at the tile is Replayed here, and vice versa
// - while locked, the code is not even checked
//
// Deliberately checks the code WITHOUT a password proof (verify.h says
// "password first" for the logon path). Acceptable here because: the gate
// grants nothing by itself (NTLM still checks the password at connection
// time), the tool is admin-only by three independent ACLs (state.lock file,
// State key, DPAPI secret), and the obvious fix is circular - LogonUserW with
// LOGON32_LOGON_NETWORK is vetoed by our own filter while the gate is closed.
//
// IMPORTANT: install this exe NEXT TO TacProvider.dll (same folder).
// Doppio's StateLock lives in the module's own directory - only the same
// directory means the same lock file, which is what makes the tile and the
// gate mutually exclusive.
//
// Run elevated: the secret value is ACL'd to SYSTEM + Administrators, and
// LoadSecretKey (DPAPI, machine scope) runs in this process.
//
// Doppio files used, unmodified: totp.cpp, store.cpp, store_index.cpp,
// verify.cpp, statelock.cpp.
#include <windows.h>
#include "verify.h"
#include "store.h"
#include <sddl.h>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <cwchar>
#include <string>
#include <vector>
#pragma comment(lib, "advapi32.lib")
verify.h and store.h come straight from the Doppio submodule. VerifyOtp from verify.h is the same function that Doppio's tile calls in Part 1:
OtpResult VerifyOtp(const std::wstring& sid, const std::wstring& code,
uint64_t now, OtpInfo& info);
It takes Doppio's StateLock, loads the account's state, checks lockout, replay and the code, and saves the state again, all under the lock. Ristretto has no code check of its own for that reason.
Paths, ACL and window
namespace
{
const wchar_t kGatePath[] = L"SOFTWARE\\TheAdminCafe\\Ristretto\\Gate";
const wchar_t kConfigPath[] = L"SOFTWARE\\TheAdminCafe\\Ristretto";
// Same ACL Doppio uses for its store (store.cpp, kKeySddl). The filter
// reads the gate as SYSTEM; a normal user cannot forge a timestamp.
const wchar_t kKeySddl[] = L"D:P(A;OICI;KA;;;SY)(A;OICI;KA;;;BA)";
// Must match ristretto_filter.cpp. The filter clamps on read as well -
// clamping on both sides means a value written by hand with regedit
// still cannot produce a gate that never closes.
const DWORD kWindowDefault = 300;
const DWORD kWindowMin = 30;
const DWORD kWindowMax = 3600;
}
The SDDL reads like this: D:P is a protected DACL, so nothing inherits in from HKLM\SOFTWARE. (A;OICI;KA;;;SY) gives SYSTEM full key access, inherited by subkeys, and the same again for BA, the built-in Administrators. Nobody else gets any access, read included. A normal user can't see whether a gate is open, and can't forge a timestamp either. Values have no ACL of their own, the key's ACL covers them. And OI does nothing on a registry key, CI alone would do.
The exe repeats the window constants from the filter. Both sides clamp, and only the filter's clamp protects anything. The exe's clamp is a courtesy to the person typing.
Time, elevation and the event log
// Identical value to _time64(nullptr) and to the filter's NowUnix():
// UTC seconds since the unix epoch. Both sides must agree exactly, so both
// now derive it the same way instead of trusting two different clocks.
static uint64_t NowUnix()
{
FILETIME ft;
GetSystemTimeAsFileTime(&ft);
ULARGE_INTEGER u;
u.LowPart = ft.dwLowDateTime;
u.HighPart = ft.dwHighDateTime;
const uint64_t kTicksToEpoch = 116444736000000000ULL;
if (u.QuadPart < kTicksToEpoch) return 0;
return (u.QuadPart - kTicksToEpoch) / 10000000ULL;
}
static bool Elevated()
{
HANDLE t = nullptr;
if (!OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &t))
return false;
TOKEN_ELEVATION e; DWORD cb = sizeof(e);
BOOL ok = GetTokenInformation(t, TokenElevation, &e, sizeof(e), &cb);
CloseHandle(t);
return ok && e.TokenIsElevated;
}
static void GateLog(WORD type, DWORD id, const std::wstring& text)
{
HANDLE h = RegisterEventSourceW(nullptr, L"TheAdminCafe Ristretto");
if (h)
{
LPCWSTR s[1] = { text.c_str() };
ReportEventW(h, type, 0, id, nullptr, 1, 0, s, nullptr);
DeregisterEventSource(h);
}
}
NowUnix is the same function as in the filter, byte for byte in its logic. One binary writes the gate and another reads it, and they have to agree on what "now" is.
Elevated checks TokenElevation before anything else happens. Without elevation every registry write would fail with "access denied" somewhere in the middle, and the error message would be a lot less helpful than "run elevated".
GateLog writes to the Application log. From the exe that's an RPC from a normal process, so the problem from the filter doesn't apply.
The registry helpers
// store.cpp's OpenProtectedKey is static; this is an intentional copy, taken
// from Doppio 2776655. Creates or opens a key below HKLM with the protected
// ACL, and re-applies the ACL on existing keys. Doppio's own OpenProtectedKey
// stopped re-applying it in 37a827d, because Doppio's SaveState runs on the
// logon path; its enroll.exe repairs the ACL once per run instead. This tool
// is a console tool like enroll.exe, so the repair stays here on purpose.
static LSTATUS OpenProtected(const wchar_t* path, HKEY* phKey)
{
PSECURITY_DESCRIPTOR psd = nullptr;
if (!ConvertStringSecurityDescriptorToSecurityDescriptorW(kKeySddl, SDDL_REVISION_1,
&psd, nullptr))
return static_cast<LSTATUS>(GetLastError());
SECURITY_ATTRIBUTES sa = { sizeof(sa), psd, FALSE };
DWORD disp = 0;
LSTATUS st = RegCreateKeyExW(HKEY_LOCAL_MACHINE, path, 0, nullptr, 0,
KEY_SET_VALUE | KEY_QUERY_VALUE | WRITE_DAC,
&sa, phKey, &disp);
if (st == ERROR_SUCCESS && disp == REG_OPENED_EXISTING_KEY)
{
st = RegSetKeySecurity(*phKey, DACL_SECURITY_INFORMATION, psd);
if (st != ERROR_SUCCESS) { RegCloseKey(*phKey); *phKey = nullptr; }
}
LocalFree(psd);
return st;
}
// Same rules as the filter's ReadConfigThrottled: no readable value means the
// default, anything else is clamped, 0 included. The old version read 0 as
// "default" and showed 300 s where the filter allowed 30. One difference is
// left on purpose: when WindowSec disappears, the filter keeps its last value
// until lsass restarts. This tool cannot see that value and shows the default.
static DWORD CurrentWindowSec()
{
DWORD v = 0, cb = sizeof(v);
if (RegGetValueW(HKEY_LOCAL_MACHINE, kConfigPath, L"WindowSec",
RRF_RT_REG_DWORD, nullptr, &v, &cb) != ERROR_SUCCESS)
return kWindowDefault;
if (v < kWindowMin) v = kWindowMin;
if (v > kWindowMax) v = kWindowMax;
return v;
}
static bool WriteTs(DWORD rid, uint64_t ts) // ts = 0 deletes the value
{
HKEY k = nullptr;
if (OpenProtected(kGatePath, &k) != ERROR_SUCCESS)
return false;
WCHAR value[16];
swprintf_s(value, L"%lu", static_cast<unsigned long>(rid)); // same convention as 2FA\Rids
LSTATUS st = ts ? RegSetValueExW(k, value, 0, REG_QWORD,
reinterpret_cast<const BYTE*>(&ts), sizeof(ts))
: RegDeleteValueW(k, value);
RegCloseKey(k);
return st == ERROR_SUCCESS || st == ERROR_FILE_NOT_FOUND;
}
static bool RidOf(const wchar_t* user, std::wstring& sid, DWORD& rid)
{
// Doppio's own resolution: normalizes, qualifies as COMPUTERNAME\user,
// requires a real local SAM account. Local-only scope comes with it.
if (!tac::ResolveLocalUserSid(user, sid))
return false;
return tac::RidFromSidString(sid, rid);
}
OpenProtected is a copy of store.cpp's OpenProtectedKey, because that one is static and not reachable from outside. It creates the key with the protected ACL, or, if the key already exists, writes the ACL again. That second part matters: if somebody loosened the ACL on the gate key by hand, the next ristretto call puts it back. Doppio's own OpenProtectedKey stopped doing the second part in its second review. There it runs on the logon path, and enroll.exe repairs the ACL once per run instead. ristretto.exe is a console tool like enroll.exe, so the repair stays in the copy, and the comment says which Doppio commit the copy came from.
WriteTs with ts = 0 deletes the value, and a value that isn't there counts as success. Closing a gate that's already closed is not an error.
This file uses swprintf_s, because in the exe an overflow only ends ristretto.exe, which is annoying and harmless. And a ULONG has at most 10 digits, so value[16] can't overflow.
RidOf uses Doppio's own account resolution. tac::ResolveLocalUserSid qualifies the name as COMPUTERNAME\user and requires a real local SAM account, so a domain account or a group can never get a gate. RidFromSidString takes the last sub-authority of the SID, the same RID the filter gets as UserAll->UserId.
CurrentWindowSec reads the window with the filter's rules: no readable value means 300, anything else is clamped, 0 included. My first version read 0 as the default and showed 300 seconds where the filter allowed 30. One difference is left: when WindowSec disappears, the filter keeps its last value until lsass restarts, and the tool can't see that value.
Opening the gate
static int Open(const wchar_t* user, const wchar_t* codeArg)
{
std::wstring sid; DWORD rid = 0;
if (!RidOf(user, sid, rid))
{
wprintf(L"'%s' is not a local user account on this machine.\n", user);
return 1;
}
WCHAR codeBuf[16] = {};
if (codeArg && *codeArg)
{
// The code is single-use and burned below, so the exposure window is
// short - but a command line is readable by any process on the box
// and lands in 4688 audit records and shell history. Prompting is
// the better habit; the argument stays for scripted use.
wprintf(L"Note: passing the code as an argument leaves it in the command line "
L"and in your shell history. Prefer running without it.\n");
wcsncpy_s(codeBuf, codeArg, _TRUNCATE);
}
else
{
wprintf(L"One-time code: ");
if (!fgetws(codeBuf, ARRAYSIZE(codeBuf), stdin))
return 1;
}
std::wstring code(codeBuf);
SecureZeroMemory(codeBuf, sizeof(codeBuf));
while (!code.empty() && (code.back() == L'\n' || code.back() == L'\r' || code.back() == L' '))
code.pop_back();
tac::OtpInfo info = {};
tac::OtpResult r = tac::VerifyOtp(sid, code, NowUnix(), info);
if (!code.empty())
SecureZeroMemory(&code[0], code.size() * sizeof(WCHAR));
switch (r)
{
case tac::OtpResult::Ok:
break;
case tac::OtpResult::NotEnrolled:
wprintf(L"'%s' is not enrolled in Doppio. Run enroll.exe first.\n", user);
return 1;
case tac::OtpResult::LockedOut:
wprintf(L"Locked. Try again in %u minutes. "
L"(The gate and the logon screen share one lockout.)\n", info.minutesLeft);
return 1;
case tac::OtpResult::Error:
wprintf(L"Could not lock/read/write the 2FA state. Nothing opened.\n");
return 1;
case tac::OtpResult::Replayed:
wprintf(L"That code was already used. Wait for the next one - and note that "
L"retrying a used code counts as a failure (%u in a row%s).\n",
info.failures, info.minutesLeft ? L", account now locked" : L"");
return 1;
default: // Wrong
if (info.minutesLeft)
wprintf(L"Invalid one-time code. %u in a row - account locked for %u minutes.\n",
info.failures, info.minutesLeft);
else
wprintf(L"Invalid one-time code. (%u in a row)\n", info.failures);
return 1;
}
// The code is burned now (lastStep was saved). If the timestamp write
// fails, the user re-runs with the next code - annoying, never unsafe.
if (!WriteTs(rid, NowUnix()))
{
wprintf(L"Code accepted, but the gate timestamp could not be written (elevated?).\n");
return 1;
}
const DWORD window = CurrentWindowSec();
GateLog(EVENTLOG_INFORMATION_TYPE, 300,
std::wstring(L"Gate opened for '") + user + L"' (RID " + std::to_wstring(rid) +
L") for " + std::to_wstring(window) + L" s.");
// The filter only runs when a logon is made. A share mapped or a WinRM
// session opened inside the window stays up after it closes, so say so.
wprintf(L"Gate open for '%s' for %lu s.\n"
L"Anyone who can authenticate as this account passes while the window lasts -\n"
L"close it with /close when you are done. Closing stops new logons only:\n"
L"a session that is already up stays up until it disconnects.\n",
user, static_cast<unsigned long>(window));
return 0;
}
Open is short because VerifyOtp does the work.
The code comes from the prompt by default. As an argument it ends up in the process command line, where other processes with enough rights can read it, in your shell history, and in 4688 records if command-line auditing is on. The code is single-use and burned a second later, so the exposure is small, but the tool says so every time you do it.
The code buffer gets SecureZeroMemory right after it's copied, and the std::wstring right after VerifyOtp. It has to be SecureZeroMemory, because the compiler may drop a memset on memory that nothing reads again.
Then the six results from verify.h, each with a message that says what happened. Replayed gets its own text, because "invalid code" for a correct code you used at the tile a moment ago would be confusing. The message also says that retrying a used code counts as a failure.
The order at the end is deliberate: VerifyOtp burns the code first by saving the time step, and then WriteTs writes the timestamp. If the write fails, you run it again with the next code, which is annoying but never unsafe. The other order would leave an open gate behind a code that somebody could use again.
The message at the end also says what the gate doesn't do. The filter only runs when a logon is made, so a session that is already up stays up after the window closes. My first version only said "close it with /close when you are done", which sounded like more than it does.
One shared lockout
The gate tool doesn't reimplement the code check. It links Doppio's totp.cpp, store.cpp, store_index.cpp, verify.cpp and statelock.cpp unmodified, as a pinned submodule, and that's what keeps the lockout honest.
If Ristretto kept its own failure counter, an attacker would get two independent sets of guesses against the same secret: five at the tile and five at the gate, each with its own doubling lock. Sharing verify.cpp means one counter, one lastStep, one state file:
- Five wrong codes in a row lock the account, at the tile, at the gate or any mix of the two, and the lock doubles from 5 to 10, 20, 40 and at most 60 minutes.
- A code burned at the logon screen is
Replayedat the gate, and the other way round. - While the account is locked,
VerifyOtpdoesn't check the code at all.
This only works if ristretto.exe sits in the same folder as TacProvider.dll, and that's easy to get wrong. Doppio's StateLock builds the path of state.lock from the folder of the module that runs the code (GetModuleFileNameW on itself). Put the exe somewhere else and you get a second lock file. Then the tile and the gate can write the state at the same time, and the shared counter isn't shared anymore. The installer copies the exe to C:\Program Files\TheAdminCafe\ for this reason.
The compromise I made on purpose
verify.h says, in so many words, to check the password before the code. Part 1 has a whole chapter on why: if the code comes first, anybody at the logon screen can lock out every enrolled account by typing garbage.
The gate checks the code without a password proof. I went back and forth on this one.
The argument for doing it: the gate grants nothing by itself. NTLM checks the password when you make the connection, so an open gate without the password or its NT hash is worth nothing. And the tool is admin-only three times over: the state lock file, the State key and the DPAPI secret are all SYSTEM and Administrators only. An attacker who can run it elevated can already switch the filter off.
The obvious fix is circular, and that settled it. To prove the password I'd call LogonUserW with LOGON32_LOGON_NETWORK, and that is a network logon, which my own filter vetoes while the gate stays closed. LOGON32_LOGON_INTERACTIVE would work, but then every gate you open writes a 4624 type 2 into the Security log, which is a lie about what happened.
I left it as it is, and the lockout exposure is the price. An elevated attacker can burn the gate's guesses and lock the account out of its own logon screen. That's a denial of service by somebody who already is an administrator and could do worse with that right.
Status, close and close all
static int Status(const wchar_t* user)
{
std::wstring sid; DWORD rid = 0;
if (!RidOf(user, sid, rid))
{
wprintf(L"'%s' is not a local user account.\n", user);
return 1;
}
DWORD mode = 1, cb = sizeof(mode);
if (RegGetValueW(HKEY_LOCAL_MACHINE, kConfigPath, L"Mode",
RRF_RT_REG_DWORD, nullptr, &mode, &cb) != ERROR_SUCCESS)
mode = 1;
if (!mode)
wprintf(L"WARNING: Mode = 0, enforcement is off. The filter lets every network "
L"logon through regardless of the gate.\n");
// The filter only gates accounts in Doppio's RID index. Without this
// check, /status said "gate closed" for an account the filter lets
// through anyway, for example after enrolling without enroll /reindex.
switch (tac::EnrollmentByRid(rid))
{
case tac::Enrollment::No:
wprintf(L"'%s' is not in Doppio's RID index, so the filter does not gate it at all. "
L"Run enroll /reindex if it should be gated.\n", user);
return 0;
case tac::Enrollment::Unknown:
wprintf(L"Doppio's RID index could not be read. The filter refuses every network "
L"logon for '%s' until it can.\n", user);
return 1;
case tac::Enrollment::Yes:
default:
break;
}
WCHAR value[16];
swprintf_s(value, L"%lu", static_cast<unsigned long>(rid));
ULONGLONG ts = 0; cb = sizeof(ts);
if (RegGetValueW(HKEY_LOCAL_MACHINE, kGatePath, value,
RRF_RT_REG_QWORD, nullptr, &ts, &cb) != ERROR_SUCCESS)
{
wprintf(L"'%s': gate closed (never opened).\n", user);
return 0;
}
const uint64_t now = NowUnix();
if (now < ts)
{
wprintf(L"'%s': clock skew, the filter treats this as closed.\n", user);
return 0;
}
const uint64_t age = now - ts;
const DWORD window = CurrentWindowSec();
if (age <= window)
wprintf(L"'%s': gate OPEN, %llu s remaining (opened %llu s ago, window %lu s).\n",
user, (unsigned long long)(window - age), (unsigned long long)age,
static_cast<unsigned long>(window));
else
wprintf(L"'%s': gate closed (expired; last unlock %llu s ago, window %lu s).\n",
user, (unsigned long long)age, static_cast<unsigned long>(window));
return 0;
}
static int Close(const wchar_t* user)
{
std::wstring sid; DWORD rid = 0;
if (!RidOf(user, sid, rid)) { wprintf(L"unknown account\n"); return 1; }
bool ok = WriteTs(rid, 0);
if (ok)
GateLog(EVENTLOG_INFORMATION_TYPE, 302,
std::wstring(L"Gate closed for '") + user + L"'.");
wprintf(ok ? L"closed. Sessions that are already up stay up.\n" : L"close failed\n");
return ok ? 0 : 1;
}
// Panic button: drop every open gate at once without having to remember who
// is enrolled. It always reads index 0 and deletes that value until the key
// is empty. Deleting while enumerating upwards shifts the indices, and the old
// fixed list of 256 names of up to 31 characters stopped early on a long name
// or a full list, then logged "All gates closed" anyway. The buffer takes the
// longest value name the registry allows, and any error is reported.
static int CloseAll()
{
HKEY k = nullptr;
if (OpenProtected(kGatePath, &k) != ERROR_SUCCESS)
{
wprintf(L"Could not open the gate key (elevated?).\n");
return 1;
}
std::vector<WCHAR> name(16384); // 16,383 characters + terminator
DWORD closed = 0;
LSTATUS st = ERROR_SUCCESS;
for (;;)
{
DWORD cch = static_cast<DWORD>(name.size());
st = RegEnumValueW(k, 0, name.data(), &cch, nullptr, nullptr, nullptr, nullptr);
if (st != ERROR_SUCCESS)
break;
st = RegDeleteValueW(k, name.data());
if (st != ERROR_SUCCESS)
break;
++closed;
}
RegCloseKey(k);
if (st != ERROR_NO_MORE_ITEMS)
{
GateLog(EVENTLOG_WARNING_TYPE, 302,
L"Closing all gates stopped after " + std::to_wstring(closed) +
L" with error " + std::to_wstring(st) + L". Gates may still be open.");
wprintf(L"closed %lu gate(s), then stopped with error %ld. Gates may still be open,\n"
L"check HKLM\\%s by hand.\n",
static_cast<unsigned long>(closed), static_cast<long>(st), kGatePath);
return 1;
}
GateLog(EVENTLOG_INFORMATION_TYPE, 302,
L"All gates closed (" + std::to_wstring(closed) + L").");
wprintf(L"closed %lu gate(s). Sessions that are already up stay up.\n",
static_cast<unsigned long>(closed));
return 0;
}
Status does the same math as the filter and prints what the filter would decide right now, including the clock skew case. If the kill switch is off, it prints a warning first. An admin who forgot /mode off from yesterday's test should notice before trusting the gate. Then it asks EnrollmentByRid, because the filter only gates accounts in Doppio's RID index. My first version said "gate closed" for an account the filter lets through anyway, for example after enrolling without enroll /reindex.
CloseAll is the panic button. It doesn't need to know who is enrolled, it deletes every value under Gate. It always reads index 0 and deletes that value, until the key is empty. Deleting while you enumerate upwards with RegEnumValueW shifts the indices, and you'd skip every second value. My first version collected the names first, in a fixed list of 256 names with up to 31 characters each. A longer name or a full list stopped it early, and the event still said "All gates closed". Now the buffer takes the longest value name the registry allows, and an error shows up in the output and in the event log.
Window and mode
static int SetWindow(const wchar_t* arg)
{
long raw = _wtol(arg);
if (raw <= 0)
{
wprintf(L"Give the window in seconds, %lu-%lu.\n",
static_cast<unsigned long>(kWindowMin), static_cast<unsigned long>(kWindowMax));
return 1;
}
DWORD v = static_cast<DWORD>(raw);
if (v < kWindowMin || v > kWindowMax)
{
DWORD clamped = v < kWindowMin ? kWindowMin : kWindowMax;
wprintf(L"%lu s is outside the allowed range %lu-%lu; using %lu s.\n",
static_cast<unsigned long>(v),
static_cast<unsigned long>(kWindowMin),
static_cast<unsigned long>(kWindowMax),
static_cast<unsigned long>(clamped));
v = clamped;
}
HKEY k = nullptr;
if (OpenProtected(kConfigPath, &k) != ERROR_SUCCESS)
{
wprintf(L"Could not open the config key (elevated?).\n");
return 1;
}
LSTATUS st = RegSetValueExW(k, L"WindowSec", 0, REG_DWORD,
reinterpret_cast<const BYTE*>(&v), sizeof(v));
RegCloseKey(k);
if (st != ERROR_SUCCESS) { wprintf(L"write failed\n"); return 1; }
wprintf(L"WindowSec = %lu (active within ~5 s, no reboot)\n",
static_cast<unsigned long>(v));
return 0;
}
static int SetMode(const wchar_t* arg)
{
const bool gate = !_wcsicmp(arg, L"gate");
if (!gate && _wcsicmp(arg, L"off"))
{
wprintf(L"Use 'gate' or 'off'.\n");
return 1;
}
DWORD v = gate ? 1u : 0u;
HKEY k = nullptr;
if (OpenProtected(kConfigPath, &k) != ERROR_SUCCESS)
{
wprintf(L"Could not open the config key (elevated?).\n");
return 1;
}
LSTATUS st = RegSetValueExW(k, L"Mode", 0, REG_DWORD,
reinterpret_cast<const BYTE*>(&v), sizeof(v));
RegCloseKey(k);
if (st != ERROR_SUCCESS) { wprintf(L"write failed\n"); return 1; }
GateLog(gate ? EVENTLOG_INFORMATION_TYPE : EVENTLOG_WARNING_TYPE, 304,
gate ? L"Enforcement enabled (Mode = 1)."
: L"Enforcement DISABLED (Mode = 0) - network logons are no longer gated.");
if (gate)
wprintf(L"Mode = gate (active within ~5 s, no reboot)\n");
else
wprintf(L"Mode = off. Enforcement is DISABLED: every network logon passes, "
L"gate or not. Re-enable with /mode gate.\n");
return 0;
}
Both write into the config key through OpenProtected, so the tool enforces the ACL again on every change. Both take effect within the filter's five-second refresh, no reboot.
SetMode accepts only gate and off and rejects everything else. Parsing a number here would turn most typos into 0, and 0 is off. Switching off writes a warning event, switching on an information event, so the kill switch leaves a trace that's easy to find.
wmain
int wmain(int argc, wchar_t** argv)
{
if (!Elevated())
{
wprintf(L"Run elevated. The 2FA store, state and lock are SYSTEM/Administrators only.\n");
return 1;
}
if (argc >= 2 && argv[1][0] != L'/')
return Open(argv[1], argc > 2 ? argv[2] : nullptr);
if (argc == 3 && !_wcsicmp(argv[1], L"/status")) return Status(argv[2]);
if (argc == 3 && !_wcsicmp(argv[1], L"/close")) return Close(argv[2]);
if (argc == 2 && !_wcsicmp(argv[1], L"/closeall"))return CloseAll();
if (argc == 3 && !_wcsicmp(argv[1], L"/window")) return SetWindow(argv[2]);
if (argc == 3 && !_wcsicmp(argv[1], L"/mode")) return SetMode(argv[2]);
wprintf(L"ristretto <user> [code] open the network gate (TOTP check)\n"
L"ristretto /status <user> show gate state and time remaining\n"
L"ristretto /close <user> close the gate now\n"
L"ristretto /closeall close every open gate\n"
L"ristretto /window <sec> gate lifetime, %lu-%lu (default %lu)\n"
L"ristretto /mode gate|off enforcement kill switch, no reboot\n",
static_cast<unsigned long>(kWindowMin),
static_cast<unsigned long>(kWindowMax),
static_cast<unsigned long>(kWindowDefault));
return argc == 1 ? 0 : 1;
}
wmain checks elevation first and then the commands. Anything that doesn't start with / is a user name, so ristretto alice opens a gate. Without arguments the tool prints its help and returns 0, with wrong arguments the same help and 1.
Four things my first draft got wrong
I wrote the filter, read it twice, thought it was fine, and then had it audited. Four findings, and three of them were things I carried over from Part 2 without looking at them again. The code above is the fixed version. This chapter shows what it looked like before, and it's the part I learned the most from.
1. swprintf_s can take lsass down
I built my log lines the way Part 2 builds them:
WCHAR text[256];
swprintf_s(text, L"Refused a network logon for enrolled account '%s' (RID %lu): "
L"no fresh gate (last unlock %lu s ago or none).", name, rid, ageSec);
On overflow, swprintf_s calls the invalid-parameter handler, and the default handler terminates the process, here lsass. That's a bugcheck, reached from the logon path, which is the one place where a bugcheck can turn into a boot loop.
It didn't overflow. With a 128-character account name and the largest possible RID and age, that line is 247 characters long, plus the terminator, in a 256-character buffer. So it worked every time I ran it. It also means the next person who adds three words to the format string ships a machine that bluescreens on the right username. Eight characters of headroom last until somebody edits the format string.
The fix is Buf from above. The same rule took _time64 out: it's CRT code on the logon path, in a file whose own header rules the CRT out. My first version of this paragraph said _time64 drags in locale machinery. It doesn't. It calls GetSystemTimeAsFileTime and converts, so the filter now calls that directly and gets the identical value.
2. The catch (...) never worked
Part 2's filter ends with a catch-all, and so did mine:
catch (...)
{
// Nothing above allocates; if that ever changes: fail closed.
}
With /EHsc, which is in the build line of both articles, a C++ catch (...) doesn't catch SEH exceptions. An access violation walks straight past it. And an access violation is the realistic failure here, because the one pointer we dereference is a UNICODE_STRING from the caller.
The handler covered std::bad_alloc, in a function without allocations. An access violation, the case I wrote it for, never reached it.
The fix is __try / __except around Decide(), with DecisionFilter deciding what to catch. In normal code, catching an access violation and carrying on is a bad idea, because you don't know what state you're in. Inside lsass the alternative is a bugcheck, so refusing one logon and staying up is the better of two bad options. That lowers the risk without removing it.
3. The enrollment check failed open
This finding mattered most, and it's four lines copied from store.cpp:
return RegGetValueW(HKEY_LOCAL_MACHINE, kDoppioRids, value,
RRF_RT_REG_DWORD, nullptr, nullptr, nullptr) == ERROR_SUCCESS;
Every failure becomes false, false means not enrolled, and not enrolled means STATUS_SUCCESS. For the credential provider that's fine, it has other checks behind it. In a filter whose only job is the veto, "I couldn't tell" must never come out as "yes". That's where the three outcomes come from. Doppio's own filter in Part 2 had the same hole. Both filters call Doppio's EnrollmentByRid from store_index.cpp now.
4. WindowSec had no ceiling
The config read accepted any non-zero DWORD:
if (RegGetValueW(..., L"WindowSec", ..., &v, &cb) == ERROR_SUCCESS && v)
g_windowSec = v;
0xFFFFFFFF seconds is about 136 years. A typo in regedit turns the gate into a door that stays propped open for the rest of the machine's life, and nothing anywhere says so. The key is admin-only, so nobody else can do this. The risk is an admin who gives themselves a permanent bypass by mistake and believes the gate works.
It's clamped to 30 to 3600 now, on both sides: in ristretto.exe /window when writing, and in the filter when reading. Only the second one matters. Clamping on write is a courtesy to the person using the tool. Clamping on read is what holds when the value didn't come from the tool.
And the smaller ones
There were more, all in SECURITY-FIXES.md in the repo:
- Concurrent logon threads wrote the config globals without synchronisation. They're interlocked now, see the config cache above.
- The allow path wrote the account name to
OutputDebugStringWon every SMB connection. That's behindRISTRETTO_FILTER_VERBOSEnow, off by default. - The installer stored
PrevAuth0 = 'TacSubAuth'when there had been noAuth0at all. Uninstalling then pointed lsass at a DLL that was never there. - The config key with the kill switch kept inherited permissions instead of the hardened ACL.
- The installer used paths relative to the current directory, and the uninstaller left open gates behind, so a reinstall could find a stale gate inside its window.
- A review of this article found more, all fixed: the installer lost its restore record on a second run, it checked LSA protection through one registry value only,
/statusignored the RID index, the tool readWindowSec = 0differently from the filter, and/closeallcould stop early. They're inSECURITY-FIXES.mdas item 16. - After the second review of Part 2, the filter got the same three contract fixes as Doppio's: a veto code from Microsoft's list, all three out-parameters on every path, and no objection to pass-through logons. They're next to the code above, and in
SECURITY-FIXES.mdas item 15.
Part 2 taught me to check what Windows expects from my code. This round added a second lesson: swprintf_s, the catch-all and the enrollment read looked fine in the code I copied them from. In lsass the same lines can take down the machine or let an enrolled account through, and that was true in the original too. Doppio's filter in Part 2 has the same three fixes now.
Build
git clone https://github.com/Nobrac/Ristretto
cd Ristretto
git submodule update --init --recursive
build-ristretto.bat
From the x64 Native Tools Command Prompt, as always. The submodule pins Doppio to one commit, so a breaking change on Doppio's side fails this build until you move the pin on purpose.
@echo off
setlocal
set DOPPIO=extern\doppio
if not exist %DOPPIO%\verify.cpp (
echo Doppio sources missing. Run: git submodule update --init --recursive
exit /b 1
)
rem The filter runs inside lsass: /Qspectre and /guard:cf are not optional
rem there, and /MT keeps a CRT DLL dependency out of the logon path.
rem /WX so a new warning in lsass-resident code fails the build.
rem
rem Two separate passes, on purpose.
rem
rem The pass that produces the binaries treats COMPILER warnings as errors
rem (/W4 /WX). That is the right bar for code that runs inside lsass.
rem
rem /analyze is a different thing: a deeper static analyser that reasons about
rem pointers and buffers. It finds real bugs, and it also reports things that
rem are correct but that it cannot prove across a call, plus deliberate
rem constructs like an SEH filter. Those are worth reading. They are not worth
rem failing a build over, and the Windows SDK's own headers raise some of them
rem (C28301, a PNTSTATUS annotation mismatch between bcrypt.h and ntsecapi.h).
rem So analysis runs afterwards, advisory, and its findings do not stop the
rem build. Read them; do not ignore them.
rem The filter links one file of Doppio's: store_index.cpp, the enrollment
rem index reads. It holds no DPAPI and no account lookups, so it is safe in
rem lsass; store.cpp, with both, stays out of this DLL.
cl /nologo /LD /MT /O2 /guard:cf /Qspectre /EHsc /std:c++17 /W4 /WX /sdl ^
/DUNICODE /D_UNICODE ^
/I%DOPPIO% src\ristretto_filter.cpp %DOPPIO%\store_index.cpp /Fe:RistrettoFilter.dll ^
/link /guard:cf /CETCOMPAT /DYNAMICBASE /NXCOMPAT /HIGHENTROPYVA ^
/DEF:src\RistrettoFilter.def advapi32.lib
if errorlevel 1 goto :fail
cl /nologo /MT /O2 /guard:cf /Qspectre /EHsc /std:c++17 /W4 /WX /sdl ^
/DUNICODE /D_UNICODE ^
/I%DOPPIO% src\ristretto.cpp %DOPPIO%\totp.cpp %DOPPIO%\store.cpp ^
%DOPPIO%\store_index.cpp %DOPPIO%\verify.cpp %DOPPIO%\statelock.cpp /Fe:ristretto.exe ^
/link /guard:cf /CETCOMPAT /DYNAMICBASE /NXCOMPAT /HIGHENTROPYVA ^
bcrypt.lib crypt32.lib advapi32.lib
if errorlevel 1 goto :fail
echo.
echo === Static analysis (advisory - does not fail the build) ===
cl /nologo /c /analyze /analyze:only /analyze:external- ^
/external:anglebrackets /external:W0 /wd28301 ^
/EHsc /std:c++17 /W4 /DUNICODE /D_UNICODE /I%DOPPIO% ^
src\ristretto_filter.cpp src\ristretto.cpp
echo === End of analysis ===
rem Clear whatever the analysis pass left in errorlevel. Its findings are
rem advisory, and the caller checks this script's exit code to decide whether
rem the build worked - which it did, above.
ver >nul
echo.
del /q *.obj *.exp 2>nul
echo Built RistrettoFilter.dll and ristretto.exe
goto :eof
:fail
echo BUILD FAILED
exit /b 1
The flags, from the outside in:
/MTlinks the C++ runtime statically. A DLL that lsass needs on the logon path and that can't find its runtime doesn't load, and you find out at boot or at the first network logon after it./guard:cfturns on Control Flow Guard, and/DYNAMICBASE /NXCOMPAT /HIGHENTROPYVAgive you ASLR with the full 64-bit range and DEP. Most of that is the linker default nowadays, but I'd rather see it written down for code in lsass./CETCOMPATmarks both binaries compatible with CET shadow stacks, the same as Doppio's build./Qspectreadds the Spectre variant 1 mitigations. This DLL shares an address space with every secret on the machine./W4 /WX /sdlmakes every compiler warning an error, plus the SDL checks. A new warning in lsass-resident code should stop the build.
/analyze runs in a second pass, and that pass is advisory. The reason is a header from Microsoft: bcrypt.h declares PNTSTATUS without SAL annotations, ntsecapi.h declares it again with them, and /analyze reports that as C28301 from inside the Windows SDK. Dropping /WX because of it would be the wrong fix. The script keeps system headers out of the analysis (/analyze:external-, /external:anglebrackets /external:W0, and /wd28301 as a backstop), the real build keeps /WX, and the analysis prints its findings without failing anything. Read them. That pass found C6054 and C6320 from the filter chapter.
ver >nul at the end of the analysis pass looks strange. It's there to reset errorlevel, so a CI job that checks the script's exit code sees the result of the build and ignores the advisory analysis.
Both passes link store_index.cpp. The DLL needs it for the enrollment read, and the exe needs it because Doppio's store.cpp calls into it.
The repo also has a test for the filter. make -C tests builds the real ristretto_filter.cpp and Doppio's store_index.cpp on Linux, against fakes for the registry and the clock, under AddressSanitizer and UBSan. Then it calls the export the way MSV1_0 would. It checks the scope and pass-through, the gate and its edges, the window bounds, the kill switch, the fail-closed paths and the out-parameters. I put six of the old behaviours back one at a time, and the test failed for each of them. It can't show how MSV1_0 really calls the filter, and __try turns into try/catch there. That part is still the VM's job. CI runs the test on every push.
Install
From an elevated PowerShell, in the build folder, after a snapshot:
.\install-ristretto.ps1
#requires -RunAsAdministrator
param(
# Skips the confirmation below, for a caller that has already asked.
# Barista passes it: it runs this through powershell.exe -NonInteractive,
# where Read-Host cannot prompt and throws instead, so without this the
# install never gets past the question. Run the script by hand and leave
# the switch off to get the prompt.
[switch]$Confirmed
)
$ErrorActionPreference = 'Stop'
# ---------------------------------------------------------------------------
# Installs RistrettoFilter into the MSV1_0 Auth0 slot and ristretto.exe next
# to TacProvider.dll.
#
# Read this before running it on anything you care about:
# * The filter is an UNSIGNED DLL that lsass loads. A fault in it is a
# bugcheck, and a bugcheck on the logon path can mean a reboot loop.
# * It needs RunAsPPL off. RunAsPPL is what stops credential dumping from
# lsass, so turning it off makes the machine materially easier to loot.
# Net effect on a production box: likely negative.
# * Test VM only. Take a snapshot first.
# ---------------------------------------------------------------------------
$sddl = 'D:P(A;OICI;KA;;;SY)(A;OICI;KA;;;BA)' # SYSTEM + Administrators, inheritance blocked
$noPrev = '__none__' # sentinel: there was no Auth0 before us
function Set-KeyAclHard {
param([Parameter(Mandatory)][string]$SubKey)
# The config key holds the Mode kill switch and WindowSec. Anyone who can
# write it can switch enforcement off, so it does not get to inherit
# whatever HKLM\SOFTWARE happens to hand out.
$sec = New-Object System.Security.AccessControl.RegistrySecurity
$sec.SetSecurityDescriptorSddlForm($sddl)
$k = [Microsoft.Win32.Registry]::LocalMachine.OpenSubKey(
$SubKey, 'ReadWriteSubTree', 'ChangePermissions')
if (-not $k) { throw "Could not open HKLM\$SubKey to set its ACL." }
try { $k.SetAccessControl($sec) } finally { $k.Close() }
}
# --- preconditions ---------------------------------------------------------
$dll = Join-Path $PSScriptRoot 'RistrettoFilter.dll'
$exe = Join-Path $PSScriptRoot 'ristretto.exe'
foreach ($f in @($dll, $exe)) {
if (-not (Test-Path -LiteralPath $f)) { throw "Missing build output: $f. Run build-ristretto.bat first." }
}
$doppioKey = 'HKLM:\SOFTWARE\TheAdminCafe\2FA'
if (-not (Test-Path "$doppioKey\Rids")) {
throw "Doppio not found ($doppioKey\Rids missing). Install and enroll with Doppio first."
}
if (-not (Test-Path 'C:\Program Files\TheAdminCafe\TacProvider.dll')) {
throw "TacProvider.dll not found - install Doppio first."
}
$lsaKey = 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa'
$msvKey = "$lsaKey\MSV1_0"
$cfgPathRel = 'SOFTWARE\TheAdminCafe\Ristretto'
$cfg = "HKLM:\$cfgPathRel"
# A domain controller keeps its accounts in the directory, not in a local SAM.
# Doppio's RID index only knows local accounts, so on a DC the filter would
# check domain RIDs against it. install-subauth.ps1 refuses for the same reason.
# DomainRole 4 = backup DC, 5 = primary DC.
if ((Get-CimInstance Win32_ComputerSystem).DomainRole -ge 4) {
throw "This is a domain controller. The filter only knows local accounts by RID; on a DC it would check domain RIDs against Doppio's index. Refusing."
}
# With LSA protection on, lsass only loads plug-ins with a Microsoft LSA
# signature, and this DLL has none. Without these checks the install looked
# fine, Auth0 changed, and the filter simply never loaded.
$lsaProps = Get-ItemProperty -Path $lsaKey
if ($lsaProps.RunAsPPL -or $lsaProps.RunAsPPLBoot) {
throw "LSA protection (RunAsPPL or RunAsPPLBoot) is on - the unsigned filter will never load. Turn it off in the test VM first."
}
# The registry values are not the whole story: a UEFI variable or Windows' own
# default for new Windows 11 devices can turn LSA protection on as well. The
# proof at runtime is WinInit event 12, "LSASS.exe was started as a protected
# process with level: 4", in the System log. Event ID 12 is used by other
# sources too, hence the provider check.
$boot = (Get-CimInstance Win32_OperatingSystem).LastBootUpTime
$pplEvent = Get-WinEvent -FilterHashtable @{ LogName = 'System'; Id = 12; StartTime = $boot } -ErrorAction SilentlyContinue |
Where-Object { $_.ProviderName -like '*Wininit*' } | Select-Object -First 1
if ($pplEvent) {
throw "lsass runs as a protected process since the last boot (WinInit event 12). It will not load the unsigned filter. Turn LSA protection off in the test VM first."
}
if (-not (Test-Path $msvKey)) { throw "$msvKey does not exist. Wrong machine?" }
$cur = (Get-ItemProperty $msvKey -Name Auth0 -ErrorAction SilentlyContinue).Auth0
if ($cur -and $cur -notin @('TacSubAuth', 'RistrettoFilter')) {
throw "Auth0 belongs to '$cur'. Not touching another product's filter."
}
# What the uninstaller will restore. Checked here, before anything changes.
# An older version of this script wiped the config key on a second run (see
# below) and then recorded its own name, so the uninstaller "restored"
# Ristretto and Doppio's TacSubAuth never came back.
$prevRecorded = $null
if (Test-Path $cfg) {
$prevRecorded = (Get-ItemProperty $cfg -Name PrevAuth0 -ErrorAction SilentlyContinue).PrevAuth0
}
if ($prevRecorded -eq 'RistrettoFilter') {
throw "PrevAuth0 under $cfg is 'RistrettoFilter', left by an older version of this script. Set it to what Auth0 was before Ristretto ('TacSubAuth' for Doppio's filter, '$noPrev' for none) and run this again."
}
if ($cur -eq 'RistrettoFilter' -and -not $prevRecorded) {
throw "Auth0 is already 'RistrettoFilter', but PrevAuth0 under $cfg is missing, so there is no record of what to restore. Set PrevAuth0 ('TacSubAuth' or '$noPrev') and run this again."
}
Write-Host ""
Write-Host "About to put an unsigned DLL in the lsass logon path on this machine." -ForegroundColor Yellow
Write-Host "If it faults, this box may not boot cleanly. Snapshot taken?" -ForegroundColor Yellow
if ($Confirmed) {
# The two warnings above are printed either way, so they are in the
# caller's log next to whatever it asked its own user.
Write-Host "Confirmed by the caller (-Confirmed)."
} else {
try {
$answer = Read-Host "Type YES to continue"
} catch {
# -NonInteractive without -Confirmed. The raw error from Read-Host
# says nothing about how to get past it.
throw "No interactive console for the confirmation. Run this in a normal PowerShell window, or pass -Confirmed if the caller has already asked."
}
if ($answer -cne 'YES') { Write-Host "Aborted. Nothing changed."; return }
}
# --- config key, hardened before anything is written into it ---------------
# Create the keys only when they are missing. New-Item -Force on a registry
# key that exists does not open it: the registry provider deletes the key with
# all its values and subkeys and creates it again. That is how a second run
# used to lose PrevAuth0, Mode, WindowSec and every open gate.
if (-not (Test-Path $cfg)) { New-Item $cfg | Out-Null }
Set-KeyAclHard -SubKey $cfgPathRel
if (-not (Test-Path "$cfg\Gate")) { New-Item "$cfg\Gate" | Out-Null }
Set-KeyAclHard -SubKey "$cfgPathRel\Gate"
# Remember what Auth0 was, including "nothing". The old code stored
# 'TacSubAuth' when there was no Auth0 at all, so uninstalling invented a
# filter that was never registered and pointed lsass at a DLL that does not
# exist on this machine.
if (-not (Get-ItemProperty $cfg -Name PrevAuth0 -ErrorAction SilentlyContinue)) {
if ($cur) { Set-ItemProperty $cfg -Name PrevAuth0 -Value $cur }
else { Set-ItemProperty $cfg -Name PrevAuth0 -Value $noPrev }
}
if (-not (Get-ItemProperty $cfg -Name Mode -ErrorAction SilentlyContinue)) {
Set-ItemProperty $cfg -Name Mode -Value 1 -Type DWord
}
if (-not (Get-ItemProperty $cfg -Name WindowSec -ErrorAction SilentlyContinue)) {
Set-ItemProperty $cfg -Name WindowSec -Value 300 -Type DWord # 5 min; filter clamps 30-3600
}
# --- event source ----------------------------------------------------------
$src = 'HKLM:\SYSTEM\CurrentControlSet\Services\EventLog\Application\TheAdminCafe Ristretto'
if (-not (Test-Path $src)) { New-Item $src | Out-Null }
Set-ItemProperty $src -Name EventMessageFile -Value '%SystemRoot%\System32\EventCreate.exe' -Type ExpandString
Set-ItemProperty $src -Name TypesSupported -Value 7 -Type DWord
# --- payload, then the registry switch -------------------------------------
# Order matters: the DLL has to be on disk before Auth0 names it, or the
# next boot looks for a filter that is not there.
Copy-Item $dll "$env:SystemRoot\System32\RistrettoFilter.dll" -Force
# Must sit next to TacProvider.dll: shared state.lock (see README).
Copy-Item $exe 'C:\Program Files\TheAdminCafe\ristretto.exe' -Force
Set-ItemProperty $msvKey -Name Auth0 -Value 'RistrettoFilter'
$shown = if ($cur) { $cur } else { '(none)' }
Write-Host ""
Write-Host "Auth0: '$shown' -> 'RistrettoFilter' (saved for restore). Reboot to load."
Write-Host "Default window is 300 s. Open a gate with: ristretto <user>"
In order, the script:
- It checks everything before it touches anything: both build outputs next to the script (
$PSScriptRoot, not the current directory), Doppio's RID index,TacProvider.dll, that the machine isn't a domain controller, that LSA protection is off (RunAsPPL,RunAsPPLBoot, and WinInit event 12 since the last boot), and thatAuth0belongs toTacSubAuth, to Ristretto, or to nobody. It won't take the slot from another product's filter. My first version only looked atRunAsPPL. With LSA protection on through any other route, the install looked fine and the filter never loaded. - It asks. You type
YES, in capitals.-cneis a case-sensitive compare, soyesaborts. You're about to put an unsigned DLL into the logon path, so it wants a deliberate answer. The one exception is-Confirmed. Barista, my installer for Doppio and Ristretto, asks its own user first and then runs this script withpowershell.exe -NonInteractive. In that modeRead-Hostcan't prompt and throws, so Barista passes the switch. The two warnings print either way and end up in Barista's log. Without a console and without the switch, the script stops with an error that tells you which of the two you need. Run it by hand and leave the switch off. - It creates the config keys if they're missing and hardens them before any value goes in. "If they're missing" matters:
New-Item -Forceon a registry key that exists doesn't open it. It deletes the key with everything in it and creates it again. My first version did exactly that.Set-KeyAclHardapplies the same SDDL as the exe. A kill switch shouldn't depend onHKLM\SOFTWAREdefaults staying what you remember. - It records what
Auth0was, including "nothing" as the__none__sentinel. It does that only if there isn't a record yet, so a second run keeps the first record. IfAuth0already saysRistrettoFilterand the record is gone, or the record itself saysRistrettoFilter, the script stops in step 1 and tells you how to fix it. My first version lost the record on a second run, see step 3, and then wrote downRistrettoFilteras the filter to restore. The uninstaller "restored" Ristretto, and Doppio's filter never came back. - It registers the event source.
EventCreate.exeas message file is a trick: it has a message table that prints the string you pass, so the events are readable in the Event Viewer without a message DLL of our own. - It copies the DLL into System32 and the exe next to
TacProvider.dll, and setsAuth0last. If the copy fails, lsass loads the old filter on the next boot.
Reboot to load it.
Using it
ristretto <user> [code] open the network gate (TOTP check)
ristretto /status <user> show gate state and time remaining
ristretto /close <user> close the gate now
ristretto /closeall close every open gate
ristretto /window <sec> gate lifetime, 30-3600 (default 300)
ristretto /mode gate|off enforcement kill switch, no reboot
A session looks like this. On the protected machine, elevated:
C:\> "C:\Program Files\TheAdminCafe\ristretto.exe" alice
One-time code: 482913
Gate open for 'alice' for 300 s.
Anyone who can authenticate as this account passes while the window lasts -
close it with /close when you are done. Closing stops new logons only:
a session that is already up stays up until it disconnects.
Then from the second machine net use \\host\share /user:host\alice connects for the next five minutes. The mapped share stays up after that, until it disconnects. ristretto /status alice tells you how much of the window is left, and ristretto /close alice ends the window when you're done. Pass the code as an argument in scripts and nowhere else.
The first thing to check
Same as in Part 2: attach the kernel debugger and watch for [Ristretto] lines on a network logon. Open a share from a second machine with the gate closed. You should see a "Refused a network logon" line and a logon error on the client, plus a failed 4625 in the Security log of the protected machine. Write down what it shows in Status and Sub Status. I expect 0xC000006E and 0xC0000070. Open the gate and try again.
No lines at all has two possible reasons. Either lsass never loaded the DLL, or MSV1_0 doesn't call the filter on your build. Rule out the first one before you believe the second: in the System log, Wininit event 12 means lsass runs as a protected process, and under Microsoft\Windows\CodeIntegrity\Operational an event 3033 or 3063 names the DLL that lsass refused. If neither is there and you still see no lines, the filter is loaded and never asked, and the gate does nothing.
Uninstall
.\uninstall-ristretto.ps1
#requires -RunAsAdministrator
$ErrorActionPreference = 'Stop'
$msvKey = 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa\MSV1_0'
$cfg = 'HKLM:\SOFTWARE\TheAdminCafe\Ristretto'
$src = 'HKLM:\SYSTEM\CurrentControlSet\Services\EventLog\Application\TheAdminCafe Ristretto'
$noPrev = '__none__'
$cur = (Get-ItemProperty $msvKey -Name Auth0 -ErrorAction SilentlyContinue).Auth0
$prev = (Get-ItemProperty $cfg -Name PrevAuth0 -ErrorAction SilentlyContinue).PrevAuth0
# An older install-ristretto.ps1 recorded its own name when it ran twice.
# Restoring that would leave Ristretto registered, so stop before anything
# changes and say how to fix the record.
if ($prev -eq 'RistrettoFilter') {
throw "PrevAuth0 under $cfg is 'RistrettoFilter', left by an older install-ristretto.ps1. Set it to what Auth0 was before Ristretto ('TacSubAuth' for Doppio's filter, '$noPrev' for none) and run this again. Nothing was changed."
}
# Restore Auth0 first: if anything below fails, the machine is already back
# on its original filter rather than half-uninstalled. Only touch it while it
# is still ours: if somebody gave the slot to another filter after the
# install, overwriting it with the old value would undo their change.
if ($cur -ne 'RistrettoFilter') {
$shown = if ($cur) { "'$cur'" } else { '(none)' }
Write-Warning "Auth0 is $shown, not 'RistrettoFilter'. Left as it is."
$restored = "$shown (unchanged)"
} elseif ($prev -and $prev -ne $noPrev) {
Set-ItemProperty $msvKey -Name Auth0 -Value $prev
$restored = "'$prev'"
} else {
# Either we recorded "there was nothing here", or the marker is gone.
# Removing the value is the safe answer - a dangling Auth0 pointing at a
# DLL that is not on disk is worse than no Auth0 at all.
Remove-ItemProperty $msvKey -Name Auth0 -ErrorAction SilentlyContinue
$restored = '(none, value removed)'
}
# Drop any open gates so a later reinstall cannot find a stale timestamp
# still inside its window.
Remove-Item "$cfg\Gate" -Recurse -Force -ErrorAction SilentlyContinue
# Clear the restore marker so a reinstall records the real previous state
# instead of reusing ours.
Remove-ItemProperty $cfg -Name PrevAuth0 -ErrorAction SilentlyContinue
Remove-Item $src -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item 'C:\Program Files\TheAdminCafe\ristretto.exe' -Force -ErrorAction SilentlyContinue
Write-Host "Auth0: $restored. Open gates cleared."
Write-Host "Reboot, THEN delete $env:SystemRoot\System32\RistrettoFilter.dll"
Write-Host "(lsass holds the DLL until the machine restarts)."
Auth0 comes first. If anything after it fails, the machine is already back on its original filter (TacSubAuth, or none at all) instead of half uninstalled. A recorded __none__ removes the value instead of inventing a filter. The script only touches Auth0 while it still says RistrettoFilter. If somebody gave the slot to another filter since, it warns and leaves it alone. If the record itself says RistrettoFilter, which an older installer wrote on a second run, the script stops before it changes anything. Then the gates go, so a later reinstall can't find a timestamp inside its window, and the restore marker goes, so a reinstall records the real previous state.
Then reboot, and only then delete RistrettoFilter.dll from System32. lsass holds the DLL until the machine restarts.
If LSA doesn't start
The rescue works as in Part 2. Restore the snapshot. If you have none, boot WinRE, load the offline hive and remove the registration by hand:
reg load HKLM\OFF C:\Windows\System32\config\SYSTEM
reg query HKLM\OFF\Select /v Current
reg query HKLM\OFF\ControlSet001\Control\Lsa\MSV1_0 /v Auth0
reg delete HKLM\OFF\ControlSet001\Control\Lsa\MSV1_0 /v Auth0
reg unload HKLM\OFF
Select\Current tells you which ControlSet00x Windows boots, and it isn't always 001. In WinRE the Windows partition is often not C:, and with BitLocker you unlock it first, Part 2 has the details. If you want Doppio's filter back instead of none, set the value to TacSubAuth instead of deleting it.
Limits
Like in the earlier parts, read this chapter even if you skip everything else.
- The window is account-wide. While a gate is open, anyone who can authenticate as that account gets in: somebody with the password, a pass-the-hash attack or a well-timed relay. A shorter window means less time for that, and the same kinds of attack. And it only covers new logons: a session opened inside the window stays up after it closes.
- You open the gate on the machine it protects, elevated. This limit decides whether Ristretto is any use to you, and I didn't see it until somebody asked me what the thing is for. You need an interactive session on the box to unlock network access to the box. On a headless server you reach over the network, that's a deadlock: you can't get in to open the gate that would let you in. Unattended access has the same problem from the other side. A backup job that pulls the share at 03:00 arrives as a network logon, and nobody is awake to open a gate for it. It also takes most of the RDP argument away: without a session you can't unlock the one NLA needs. The remaining case: you already have console or physical access and want network access from a second machine for a while. That case is real but narrow.
- Nothing checks a second factor at connection time. The NTLM chapter explains why, and the whole design starts from that.
RunAsPPLhas to be off, and that's a real downgrade. PPL is what stops credential-dumping tools from reading lsass memory. Turning it off to load an unsigned filter trades a strong, broad mitigation for a narrow one. On a machine that matters, I'd expect that trade to come out negative. This stays test-VM code until I sign the DLL and PPL can stay on.- A fault in the filter is a bugcheck. The
__excepthelps, but lsass-resident code stays dangerous. - The open question from Part 2 remains: does MSV1_0 call the filter for local accounts? Microsoft documents
Auth0for domain controllers. If you never see a[Ristretto]line in the debugger, the filter is loaded and never asked, and the gate does nothing. - The filter leaves batch, service,
runasand UAC alone, as in Part 2. Keep the deny rights on for those. - The whole design sits on NTLM, and Microsoft is moving local accounts off it. LocalKDC, in preview since June 2026 and off by default, lets a local account authenticate over the network with Kerberos. That logon goes through the Kerberos package, not through the MSV1_0 path this filter hooks. I haven't tested what the gate does then. If you turn LocalKDC on, test that first.
- The gate tool checks the code without a password, see above. An elevated attacker can burn the shared lockout.
- Clock changes can only close the gate. A clock moved back reads as skew and the filter treats it as closed. A clock moved forward makes the gate expire early. Both directions fail closed, which also means an NTP correction or a VM snapshot restore can shut a gate you opened a minute ago.
- One function copies Doppio logic:
OpenProtectedcopiesOpenProtectedKey, and the two differ on purpose now. The submodule pin catches a changed signature, and it misses a changed meaning, in the copy and in the files the build links. Before you trust a build with a moved pin, read the diff ofstore.cpp,store_index.cppandverify.cpp, and run the test. - I haven't tested any of it on a live LSA. It compiles for x64, it exports the one symbol, the decision path allocates nothing, and the test runs the real filter against a fake registry. That's the same honest position Part 2 ended on, and it hasn't improved.
And one limit of the idea itself: a gate never gives you a second factor on every network logon. For that you need the SSP/AP, done right, which is the part I still owe you.
Where this goes next
Three parts in, local Windows accounts have a second factor at the console (Part 1), a veto inside LSA for everything that doesn't reach the tile (Part 2), and a way to open the network path again without giving up the factor (this one). None of it is production code. I built all of it to learn the layer.
My list for next, ordered by how much I want each one:
- Sign the DLL. Then
RunAsPPLcan stay on, and the whole project stops being a VM exercise. This is the real blocker. Microsoft's page on configuring additional LSA protection explains what PPL protects and what a module needs to load under it. - The SSP/AP, done right. That's the Part 3 I promised at the end of Part 2: signed, PPL-compatible, working with Credential Guard, making an enforcement decision and never keeping a credential. I built the gate because that one is hard, and it stays on the list.
- Push approval instead of a gate. A connection that waits a few seconds for a tap on the phone is a real per-connection second factor. It needs a service and somewhere to send the push, and the filter can't block on the logon path, so I don't know the shape of it yet. I find that an interesting problem.
- Bind the window to a client. I looked at binding a gate to the client workstation name from
NETLOGON_NETWORK_INFO. The client supplies that name and can write anything into it, so the check would protect nothing. I left it out on purpose.
All of it is on GitHub as Ristretto, MIT licensed like Doppio. If you build on it, or if you find the fifth thing I got wrong, I'd like to hear about it.
Licensed under CC BY-NC-SA 4.0.
