Foreword
This is the second part. In Part 1 we built a working TOTP second factor for the Windows logon: a credential provider with a tile, a filter that hides the other tiles, replay protection, a lockout and logging.
At the end of that article I kept repeating the same limit. The credential provider only sits in the UI, so it only sees the UI. Network logon over SMB, runas, scheduled tasks, WinRM, service logons: none of them touch our tile, so none of them ask for a code. I wrote back then that the check would have to move into LSA, and that maybe I would try that in another article.
This is that article.
So this time we write code that runs inside lsass.exe. And this is a different kind of dangerous than Part 1. A broken credential provider costs you a tile and you can still get in over Safe Mode. A broken authentication package can stop LSA from starting, and a machine without LSA does not boot. Not even in Safe Mode.
Important: Everything in this article is a throwaway-VM exercise. Take a snapshot before every single reboot. Have your BitLocker recovery key ready and know how to edit the registry offline from WinRE. If you only want to harden a real machine, read the chapter "Two ways to close the non-interactive paths" and then stop.
I also want to be honest about the state of the code. It compiles for x64, and the exports are checked. I have not run it on a live LSA. Don't trust it until you have, under a kernel debugger, in a VM you can roll back.
Correction, October 2026: The first version of this article used the wrong hook for the MSV1_0 part. It used
Msv1_0SubAuthenticationRoutineand said that MSV1_0 still checks the password after the routine says yes. That's wrong. If this routine is used, the password check is its job, and my routine never did one. So for a logon that selected it, "yes" meant: approved without a password. A code audit found this. The code and this article now useMsv1_0SubAuthenticationFilter. It runs after MSV1_0 checked the password and can only say no. If you installed the oldTacSubAuth.dll, run the newinstall-subauth.ps1oruninstall-subauth.ps1. Both remove the old registration. The chapter "The sub-authentication filter" explains what went wrong.
All the code is in this article again. The project is on GitHub: Doppio.
Prerequisites
- Part 1, or at least the repo. The LSA code uses the same secret store.
- A Windows 10/11 or Server VM that you can delete. Not a VM you care about.
- A snapshot. Before every reboot, not once at the beginning.
- Visual Studio 2026 or 2022 (Community is enough) or the Build Tools, with the "Desktop development with C++" workload.
- Your BitLocker recovery key, if the VM is encrypted.
- A WinRE way to edit the registry offline. Boot the install media, open a command prompt,
reg loadthe offlineSYSTEMhive. Practice this before you need it. RunAsPPLturned off on the test VM. More about this in the install chapter, because it will otherwise cost you an evening.- Ideally a kernel debugger. You cannot attach Visual Studio to lsass.
What this closes and what not
Same as in Part 1, I want this clear before any code.
Closed, if MSV1_0 calls our filter: Network logons for an enrolled account, by the sub-authentication filter. Those are the SMB and remote-access paths from the list above. The "if" is real. Microsoft documents the filter for domain controllers, and whether MSV1_0 also calls it for local accounts on your workstation is the first thing you have to check in the VM.
Not closed: Batch and service logons. They don't arrive at MSV1_0 as network logons, so our filter has no objection. For those you still need the user rights from the next chapter.
Also not closed: runas, the UAC credential prompt and any program that calls LogonUser interactively. Those are interactive logons without our tile. No user right separates them from the console logon, and the filter has no reason to refuse them. They still take the password alone.
A side effect: RDP with Network Level Authentication checks the password with a network logon before the session starts. For an enrolled account the filter refuses that, just like the deny right does. More about this in the install chapter.
And the authentication package closes nothing in practice. It is only ever called when a logon addresses it by package id, which no normal logon does. It is in the project because it teaches the layer. I will not pretend otherwise.
So the honest summary: the LSA code here is where the decision lives. The user rights are where the enforcement lives. You want both.
Two ways to close the non-interactive paths
There are two ways to do this, and it is worth being clear which is which.
The supported way needs no code at all. Windows already has the switch: the "Deny log on ..." user rights. There is one each for network, batch, services and RDP. Set them for the account and those paths are closed, while the console logon with the 2FA tile keeps working. There is a script for it in the repo:
.\deny-noninteractive.ps1 alice
On a real machine this is the answer. It survives reboots, it cannot crash anything, and it took me ten minutes to write. If your goal is hardening, do this and skip the rest of the article.
The educational way is to write the code that makes that decision yourself, inside LSA. You do not need it for the hardening above. You want it if you want to understand the layer, or build toward something bigger later: push approval, central policy, a TPM-bound secret. That is the rest of this article.
How LSA fits together
The extension points
LSA is a collection of packages. MSV1_0 checks passwords against the local SAM, Kerberos does domain tickets, Negotiate picks between them. Each one is a DLL, and Windows has a few documented ways to add your own. For sub-authentication there are two different hooks, and the difference matters a lot. I learned that the hard way:
| Extension point | Registry | Called |
|---|---|---|
| Authentication package | ...\Lsa\Authentication Packages | only for logons that address it by id |
| Sub-authentication routine | ...\Lsa\MSV1_0\Auth1 ... AuthN | by MSV1_0, only for logons that select it, and then it has to check the password itself |
| Sub-authentication filter | ...\Lsa\MSV1_0\Auth0 | by MSV1_0, after it has checked the password, to veto |
| Security Support Provider (SSP/AP) | ...\Lsa\Security Packages | for logons across the board |
| Password filter | ...\Lsa\Notification Packages | on password changes |
We build the authentication package and the filter. The SSP/AP is the interesting and dangerous one, and it gets its own chapter at the end, including why I am not building it.
Where our two pieces sit
This is the part that took me longest to get straight, so here it is as a picture:
The important difference is who calls whom. Our authentication package waits to be addressed and is therefore mostly idle. Our sub-auth filter is called by MSV1_0, from inside the processing of a logon that MSV1_0 is already handling, after the password check. That is why it sees traffic the other one never will.
Architecture of the LSA part
Part 2 adds these files to the project:
| File | Purpose |
|---|---|
ap.h/.cpp | The authentication package. Denies non-interactive logons addressed to it |
subauth_filter.h, subauth.cpp | The MSV1_0 sub-authentication filter (Auth0). The part that can actually refuse something |
TacAuthPackage.def, TacSubAuth.def | The exports, because LSA looks them up by name |
install-authpackage.ps1, uninstall-authpackage.ps1 | Registration of the authentication package |
install-subauth.ps1, uninstall-subauth.ps1 | Registration of the sub-auth filter |
deny-noninteractive.ps1 | The supported way, no code in LSA |
build-lsa.bat | Builds only the two LSA DLLs |
And it uses two small functions in the store from Part 1. They turned out to be the whole difficulty of this article, so they get their own chapter.
The authentication package
What LSA calls
You add your DLL's base name to a single registry value:
HKLM\SYSTEM\CurrentControlSet\Control\Lsa
Authentication Packages = msv1_0 (REG_MULTI_SZ)
LSA loads it at boot and calls the functions it exports, by name. The ones that matter:
LsaApInitializePackageruns once, at load. You get a dispatch table with LSA's own heap allocator, and you hand back your package name.LsaApLogonUserEx2runs once per logon that targets your package. This is where the decision happens.LsaApCallPackageand its two siblings are a side channel for tools to talk to the package later. We stub them.LsaApLogonTerminatedis cleanup when a logon session ends.
Because the exports are looked up by name, they are plain C linkage and listed in a .def file. Nothing decorated.
ap.h:
#pragma once
//
// Doppio LSA authentication package (SKELETON).
//
// This runs inside LSA (lsass.exe), not in LogonUI. An authentication package
// is only invoked for logons that address it by package id, so by itself it
// does NOT see the standard network/batch logon paths - those go to MSV1_0 /
// Negotiate. To gate those, see the sub-authentication filter (subauth.cpp),
// which MSV1_0 calls after it has validated a logon.
//
// Policy of this skeleton: for an enrolled account, deny every NON-interactive
// logon that reaches us. Interactive and unlock are left to the credential
// provider, which already asks for the TOTP.
//
// Be clear about what that is worth: NOTHING as a security control. A logon
// only reaches this package if the caller asks for it by package id, and every
// such logon fails anyway - denied, or declined with STATUS_NOT_IMPLEMENTED,
// because this package never builds a token. No real logon path is closed by
// loading it. It is here to show the LSA package interface and the logging,
// nothing more. The path that actually matters is the sub-authentication
// filter (subauth.cpp) or, on a real machine, the user rights.
//
// WARNING: this code runs in lsass.exe. A bug here does not fail one tile - it
// can crash LSA and leave the machine unbootable, past Safe Mode. Only ever
// load it in a throwaway VM with a snapshot, and debug it over a kernel
// debugger (WinDbg on a second machine). See the article chapter
// "2FA inside LSA" for the setup and the honest limits.
//
// The exports are looked up by name by LSA, so they are C-linkage and listed
// in TacAuthPackage.def.
#define SECURITY_WIN32
#include <windows.h>
#include <sspi.h>
#include <ntsecapi.h>
#include <ntsecpkg.h>
extern "C"
{
NTSTATUS NTAPI LsaApInitializePackage(
ULONG AuthenticationPackageId,
PLSA_DISPATCH_TABLE LsaDispatchTable,
PLSA_STRING Database,
PLSA_STRING Confidentiality,
PLSA_STRING* AuthenticationPackageName);
NTSTATUS NTAPI LsaApLogonUserEx2(
PLSA_CLIENT_REQUEST ClientRequest,
SECURITY_LOGON_TYPE LogonType,
PVOID ProtocolSubmitBuffer,
PVOID ClientBufferBase,
ULONG SubmitBufferSize,
PVOID* ProfileBuffer,
PULONG ProfileBufferSize,
PLUID LogonId,
PNTSTATUS SubStatus,
PLSA_TOKEN_INFORMATION_TYPE TokenInformationType,
PVOID* TokenInformation,
PUNICODE_STRING* AccountName,
PUNICODE_STRING* AuthenticatingAuthority,
PUNICODE_STRING* MachineName,
PSECPKG_PRIMARY_CRED PrimaryCredentials,
PSECPKG_SUPPLEMENTAL_CRED_ARRAY* CachedCredentials);
NTSTATUS NTAPI LsaApCallPackage(
PLSA_CLIENT_REQUEST ClientRequest, PVOID ProtocolSubmitBuffer,
PVOID ClientBufferBase, ULONG SubmitBufferLength,
PVOID* ProtocolReturnBuffer, PULONG ReturnBufferLength,
PNTSTATUS ProtocolStatus);
NTSTATUS NTAPI LsaApCallPackageUntrusted(
PLSA_CLIENT_REQUEST ClientRequest, PVOID ProtocolSubmitBuffer,
PVOID ClientBufferBase, ULONG SubmitBufferLength,
PVOID* ProtocolReturnBuffer, PULONG ReturnBufferLength,
PNTSTATUS ProtocolStatus);
NTSTATUS NTAPI LsaApCallPackagePassthrough(
PLSA_CLIENT_REQUEST ClientRequest, PVOID ProtocolSubmitBuffer,
PVOID ClientBufferBase, ULONG SubmitBufferLength,
PVOID* ProtocolReturnBuffer, PULONG ReturnBufferLength,
PNTSTATUS ProtocolStatus);
VOID NTAPI LsaApLogonTerminated(PLUID LogonId);
}
The includes need a word of explanation, because I lost time on them. ntsecpkg.h does not compile on its own. You need SECURITY_WIN32 defined and sspi.h included before it, otherwise you get a wall of errors about undeclared types. And ntstatus.h clashes with windows.h, so instead of including it I define the status values I return by hand.
The logon type
LsaApLogonUserEx2 gets handed the SECURITY_LOGON_TYPE directly. That is an enum, and it tells you exactly what kind of logon this is without parsing anything:
| Value | Name | What it is |
|---|---|---|
| 2 | Interactive | console logon |
| 3 | Network | SMB and most remote access |
| 4 | Batch | scheduled tasks |
| 5 | Service | service accounts |
| 7 | Unlock | unlock after locking the screen |
| 8 | NetworkCleartext | e.g. IIS basic auth |
| 9 | NewCredentials | runas /netonly |
| 10 | RemoteInteractive | RDP |
| 11 | CachedInteractive | cached domain credentials |
Interactive, Unlock and RemoteInteractive all have a UI where our tile from Part 1 asks for the code. Those we let through. The five in the middle have no UI, so for an enrolled account there is no second factor possible, and the honest answer is to refuse them.
Reading the account name from the submit buffer
In my first draft I left one thing deliberately off: reading which account is logging in. Without it the package can only deny everyone, which is useless. With it, doing it safely is the whole lesson.
The account name lives in the submit buffer, in a UNICODE_STRING. The catch is that its Buffer pointer is valid in the client's address space, not ours. LSA hands us ClientBufferBase, the address the buffer had over there, so we can relocate it into our own copy.
Get this wrong and you dereference a wild pointer inside lsass, at boot. So every step is bounds-checked, and anything that does not add up returns an empty string, which makes the caller treat the account as not enrolled and let the logon pass.
Why the check is a plain registry read
My first version did the obvious thing. Resolve the name to a SID with LookupAccountName, then call LoadSecretKey from Part 1 to decrypt the secret and confirm it exists. That works on a bench, and it is exactly how you get a boot loop.
The reason is where this code runs. The sub-authentication filter runs inside MSV1_0's logon processing, inside lsass, with LSA's locks held. LookupAccountName calls back into LSA. CryptUnprotectData with machine scope calls back into lsass's own DPAPI. Re-entering LSA from a call LSA is already in the middle of is how you deadlock the logon path.
Think this through for a second. You will not see it in a user-mode test, because in a test your process is not lsass and there is no lock to deadlock on. You see it when the VM hangs at boot with no error and no event, and you reach for the snapshot.
So the decision has to run without re-entering LSA. The secret stays keyed by SID and DPAPI-encrypted, for the credential provider. On top of that, StoreSecret also writes two tiny indexes under a locked-down key: one keyed by the RID of the account, one by its name. Just markers, no secret. The hot path does a single registry read, which is served by the kernel's configuration manager and not by LSA's auth logic, so it is safe to call from here.
Added to store.h:
// The last sub-authority of a SID string, the RID ("...-1001" -> 1001).
// Returns false for anything that is not a well-formed SID string.
bool RidFromSidString(const std::wstring& sid, DWORD& rid);
// Hot-path enrollment checks for code that runs INSIDE lsass. Each is a
// single registry read: no DPAPI decrypt and no account-name or SID lookup,
// so nothing calls back into LSA and the logon path cannot deadlock.
//
// IsEnrolledByRid is the one to use whenever the caller has the account's
// RID from the SAM (the MSV1_0 sub-authentication filter gets it in
// USER_ALL_INFORMATION). A RID survives a rename and the SAM never hands
// out a RID twice, so this index can neither miss a renamed account nor
// match a different, re-created one.
bool IsEnrolledByRid(DWORD rid);
// IsEnrolledByName is only for callers that have nothing but the name a
// client typed (the authentication package). It is weaker, and fails OPEN
// in one case: after an enrolled account is renamed, the index still holds
// the old name, so the new name reads as "not enrolled" until
// `enroll /reindex` runs. A re-created account with an old name reads as
// enrolled (more restrictive). Prefer IsEnrolledByRid.
bool IsEnrolledByName(const std::wstring& user);
Added to store.cpp:
// Enrollment indexes for the LSA hot path (presence only, no secret).
static const wchar_t kNamePath[] = L"SOFTWARE\\TheAdminCafe\\2FA\\Names";
static const wchar_t kRidPath[] = L"SOFTWARE\\TheAdminCafe\\2FA\\Rids";
bool RidFromSidString(const std::wstring& sid, DWORD& rid)
{
rid = 0;
if (!IsSidString(sid))
return false;
size_t dash = sid.find_last_of(L'-');
if (dash == std::wstring::npos || dash + 1 >= sid.size() || sid.size() - dash - 1 > 10)
return false;
uint64_t value = 0;
for (size_t i = dash + 1; i < sid.size(); ++i)
{
if (sid[i] < L'0' || sid[i] > L'9')
return false;
value = value * 10 + static_cast<uint64_t>(sid[i] - L'0');
}
if (value == 0 || value > 0xFFFFFFFFull)
return false;
rid = static_cast<DWORD>(value);
return true;
}
// SID string -> normalized local account name. Used only at enroll time (a
// console app), never on the LSA hot path.
static bool NameFromSid(const std::wstring& sidStr, std::wstring& name)
{
name.clear();
PSID psid = nullptr;
if (!ConvertStringSidToSidW(sidStr.c_str(), &psid))
return false;
WCHAR n[256] = {}; DWORD cn = ARRAYSIZE(n);
WCHAR d[256] = {}; DWORD cd = ARRAYSIZE(d);
SID_NAME_USE use;
BOOL ok = LookupAccountSidW(nullptr, psid, n, &cn, d, &cd, &use);
LocalFree(psid);
if (!ok)
return false;
name = NormalizeUser(n);
return !name.empty();
}
bool IsEnrolledByRid(DWORD rid)
{
if (rid == 0)
return false;
// A single registry read, like IsEnrolledByName below.
HKEY hKey = nullptr;
if (RegOpenKeyExW(HKEY_LOCAL_MACHINE, kRidPath, 0, KEY_QUERY_VALUE, &hKey) != ERROR_SUCCESS)
return false;
WCHAR value[16];
swprintf_s(value, L"%lu", static_cast<unsigned long>(rid));
LSTATUS status = RegQueryValueExW(hKey, value, nullptr, nullptr, nullptr, nullptr);
RegCloseKey(hKey);
return status == ERROR_SUCCESS;
}
bool IsEnrolledByName(const std::wstring& user)
{
std::wstring name = NormalizeUser(user);
if (name.empty())
return false;
// A single registry read. No DPAPI, no LookupAccount* - nothing that
// re-enters LSA - so this is safe to call from inside lsass on the
// logon path.
HKEY hKey = nullptr;
if (RegOpenKeyExW(HKEY_LOCAL_MACHINE, kNamePath, 0, KEY_QUERY_VALUE, &hKey) != ERROR_SUCCESS)
return false;
LSTATUS status = RegQueryValueExW(hKey, name.c_str(), nullptr, nullptr, nullptr, nullptr);
RegCloseKey(hKey);
return status == ERROR_SUCCESS;
}
And StoreSecret writes the indexes first and the secret second:
// Writes a presence flag (REG_DWORD 1) under one of the index keys.
static bool SetFlag(const wchar_t* path, const std::wstring& value)
{
HKEY hKey = nullptr;
if (OpenProtectedKey(path, &hKey) != ERROR_SUCCESS)
return false;
DWORD one = 1;
bool ok = RegSetValueExW(hKey, value.c_str(), 0, REG_DWORD,
reinterpret_cast<const BYTE*>(&one), sizeof(one)) == ERROR_SUCCESS;
RegCloseKey(hKey);
return ok;
}
// Writes the RID index and, if the SID still resolves to a name, the name
// index for one enrolled SID.
static void WriteIndexes(const std::wstring& sid, bool& ridOk, bool& nameOk)
{
ridOk = nameOk = false;
DWORD rid = 0;
if (RidFromSidString(sid, rid))
ridOk = SetFlag(kRidPath, std::to_wstring(rid));
std::wstring name;
if (NameFromSid(sid, name))
nameOk = SetFlag(kNamePath, name);
}
bool StoreSecret(const std::wstring& sid, const std::string& base32Secret)
{
if (!IsSidString(sid))
return false;
std::vector<BYTE> blob;
if (!Protect(base32Secret, blob))
return false;
// The indexes the LSA hot path reads come first. If they cannot be
// written, nothing else is touched, so a re-enrollment that fails
// leaves the old enrollment working. If the secret then fails, the
// index entries stay behind: that only makes the LSA packages refuse
// MORE (network logon of an account without a secret), never less,
// and `enroll /remove` clears them.
bool ridOk = false, nameOk = false;
WriteIndexes(sid, ridOk, nameOk);
if (!ridOk || !nameOk)
return false;
HKEY hKey = nullptr;
LSTATUS status = OpenProtectedKey(kKeyPath, &hKey);
if (status == ERROR_SUCCESS)
{
status = RegSetValueExW(hKey, sid.c_str(), 0, REG_BINARY,
blob.data(), static_cast<DWORD>(blob.size()));
RegCloseKey(hKey);
}
return status == ERROR_SUCCESS;
}
The order matters. A secret without an index is bad: the tile works, and the LSA part silently thinks the account is not enrolled. My first version wrote the secret first and deleted it again if the index failed. When you enrolled someone again, that destroyed an enrollment that worked. Now the indexes come first. If they can't be written, nothing else is changed and the old enrollment keeps working. If the secret fails after the indexes, the markers stay. That only makes the LSA side refuse more, never less, and enroll.exe /remove deletes them.
Why two indexes? The first version only had the name. Part 1 has a whole chapter on why the secret belongs to the SID, and the name index brought the old weakness back. I wrote that it only fails in the strict direction. That was wrong. If you delete alice and create a new alice, the new account reads as enrolled. Too strict, fine. But if you rename alice to alicia, the index still says alice. Now alicia reads as not enrolled, and her network logons work with the password alone. Too loose.
The filter doesn't need the name at all. MSV1_0 gives it the account record from the SAM, and the RID in there is the last part of the SID. It stays the same after a rename, and the SAM never gives the same RID to a new account. So the filter asks the RID index. That one really is as safe as I thought the name index was. Only the authentication package still reads the name, because it only has what the client typed. There it doesn't matter, as you will see in the next chapter. After a rename, or after an update from the old version, run enroll.exe /reindex once.
Why it can only deny
Now the part that decides how big the project gets. In LsaApLogonUserEx2 you can do two things: deny, or let the logon succeed.
Denying is one line. You return STATUS_ACCOUNT_RESTRICTION and LSA refuses.
Letting a logon succeed is a completely different amount of work. On success your package has to build the whole result: a logon token, a profile buffer, the account SID, the group memberships, everything the real packages spend thousands of lines producing. You do not want to reimplement MSV1_0. And a package that sits transparently in front of MSV1_0 to pass credentials through is exactly the shim that credential-theft tooling uses. MITRE ATT&CK lists it under T1556, the same entry as the malicious credential provider from Part 1. I am not going to write that.
So the package only ever denies or declines. The decline case returns STATUS_NOT_IMPLEMENTED, and that one is easy to misread, so to be precise: it means "this package does not handle this logon". It is not a transparent retry against MSV1_0. A caller that selected us by package id and gets this back has its logon fail right there. We are not politely stepping aside in a chain, we are declining to claim a logon that is not ours.
ap.cpp:
#include "ap.h"
#include "store.h"
#include <string>
#include <vector>
#pragma comment(lib, "advapi32.lib")
// NTSTATUS values we return. They live in ntstatus.h, which clashes with
// windows.h, so we define the handful we need.
#ifndef STATUS_SUCCESS
#define STATUS_SUCCESS ((NTSTATUS)0x00000000L)
#endif
#ifndef STATUS_ACCOUNT_RESTRICTION
#define STATUS_ACCOUNT_RESTRICTION ((NTSTATUS)0xC000006EL) // "account not allowed to log on here/now"
#endif
#ifndef STATUS_NOT_IMPLEMENTED
#define STATUS_NOT_IMPLEMENTED ((NTSTATUS)0xC0000002L)
#endif
#ifndef STATUS_INVALID_PARAMETER
#define STATUS_INVALID_PARAMETER ((NTSTATUS)0xC000000DL)
#endif
#ifndef STATUS_INSUFFICIENT_RESOURCES
#define STATUS_INSUFFICIENT_RESOURCES ((NTSTATUS)0xC000009AL)
#endif
// Every export below runs inside lsass.exe. The helpers use std::wstring,
// which can throw std::bad_alloc; each export therefore has a catch-all that
// turns any C++ exception into a failure status. An exception that reached
// LSA would take lsass, and with it the whole machine, down.
// Kerberos message types we recognise in a submit buffer.
#ifndef KerbInteractiveLogon
#define KerbInteractiveLogon 2
#endif
#ifndef KerbWorkstationUnlockLogon
#define KerbWorkstationUnlockLogon 7
#endif
namespace
{
PLSA_DISPATCH_TABLE g_lsa = nullptr;
ULONG g_packageId = 0;
// Logging from inside lsass. OutputDebugStringW is the channel you actually
// read under a kernel debugger. ReportEvent is best-effort on top, guarded
// so a logging failure can never affect a logon. We never log a password or
// a code, only metadata.
void ApLog(WORD type, DWORD id, const std::wstring& text)
{
OutputDebugStringW(L"[Doppio-AP] ");
OutputDebugStringW(text.c_str());
OutputDebugStringW(L"\n");
HANDLE h = RegisterEventSourceW(nullptr, L"TheAdminCafe 2FA");
if (h)
{
LPCWSTR s[1] = { text.c_str() };
ReportEventW(h, type, 0, id, nullptr, 1, 0, s, nullptr);
DeregisterEventSource(h);
}
}
bool IsNonInteractive(SECURITY_LOGON_TYPE t)
{
switch (t)
{
case Network: // 3
case Batch: // 4
case Service: // 5
case NetworkCleartext: // 8
case NewCredentials: // 9
return true;
default:
return false;
}
}
const wchar_t* LogonTypeName(SECURITY_LOGON_TYPE t)
{
switch (t)
{
case Interactive: return L"Interactive";
case Network: return L"Network";
case Batch: return L"Batch";
case Service: return L"Service";
case Unlock: return L"Unlock";
case NetworkCleartext: return L"NetworkCleartext";
case NewCredentials: return L"NewCredentials";
case RemoteInteractive: return L"RemoteInteractive";
case CachedInteractive: return L"CachedInteractive";
default: return L"Other";
}
}
// A UNICODE_STRING inside the submit buffer has a Buffer pointer that is
// valid in the CLIENT process, not ours. LSA gives us ClientBufferBase (the
// address the buffer had in the client) so we can relocate it into our copy.
// Everything is bounds-checked against the submit buffer; anything off
// returns an empty string, and the caller fails safe.
std::wstring ReadClientString(const UNICODE_STRING& us,
const void* submitBase, const void* clientBase,
ULONG submitSize)
{
if (!us.Buffer || us.Length == 0 || (us.Length % sizeof(WCHAR)) != 0)
return std::wstring();
const ULONG_PTR p = reinterpret_cast<ULONG_PTR>(us.Buffer);
const ULONG_PTR base = reinterpret_cast<ULONG_PTR>(clientBase);
if (p < base)
return std::wstring();
const ULONG_PTR off = p - base;
if (off > submitSize || us.Length > submitSize - off)
return std::wstring();
const WCHAR* s = reinterpret_cast<const WCHAR*>(
static_cast<const BYTE*>(submitBase) + off);
return std::wstring(s, us.Length / sizeof(WCHAR));
}
// The account check. We read ONLY the user name from the submit buffer
// (never the password field) and ask the store, with a single registry
// read, whether that name is enrolled. IsEnrolledByName does no DPAPI and
// no LSA name/SID lookup, so it is safe on the logon path. Any
// inconsistency returns false (treat as not enrolled -> defer), so a
// malformed buffer cannot crash us.
//
// The name is what the CLIENT typed, and the name index is not updated by
// a rename (see store.h), so this check is weaker than the RID check in
// the sub-authentication filter. It does not matter here - this package
// never lets a logon through either way (see ap.h) - but do not copy it
// into anything that does.
bool IsEnrolledAccount(PVOID submit, ULONG size, PVOID clientBase)
{
if (!submit || size < sizeof(KERB_INTERACTIVE_LOGON))
return false;
auto* k = static_cast<const KERB_INTERACTIVE_LOGON*>(submit);
if (k->MessageType != KerbInteractiveLogon &&
k->MessageType != KerbWorkstationUnlockLogon)
return false;
std::wstring user = ReadClientString(k->UserName, submit, clientBase, size);
if (user.empty())
return false;
return tac::IsEnrolledByName(user);
}
}
NTSTATUS NTAPI LsaApInitializePackage(
ULONG AuthenticationPackageId,
PLSA_DISPATCH_TABLE LsaDispatchTable,
PLSA_STRING /*Database*/,
PLSA_STRING /*Confidentiality*/,
PLSA_STRING* AuthenticationPackageName)
{
if (!LsaDispatchTable || !AuthenticationPackageName)
return STATUS_INVALID_PARAMETER;
*AuthenticationPackageName = nullptr;
g_lsa = LsaDispatchTable;
g_packageId = AuthenticationPackageId;
static const char kName[] = "TacAuthPackage";
const USHORT len = sizeof(kName) - 1;
char* buf = static_cast<char*>(g_lsa->AllocateLsaHeap(len + 1));
if (!buf)
return STATUS_NO_MEMORY;
memcpy(buf, kName, len + 1);
LSA_STRING* name = static_cast<LSA_STRING*>(g_lsa->AllocateLsaHeap(sizeof(LSA_STRING)));
if (!name)
{
g_lsa->FreeLsaHeap(buf);
return STATUS_NO_MEMORY;
}
name->Length = len;
name->MaximumLength = len + 1;
name->Buffer = buf;
*AuthenticationPackageName = name;
try
{
ApLog(EVENTLOG_INFORMATION_TYPE, 200,
L"LSA authentication package loaded (id " + std::to_wstring(AuthenticationPackageId) + L").");
}
catch (...)
{
// The log line is optional; the package is loaded either way.
}
return STATUS_SUCCESS;
}
// We only ever DENY or decline here; we never mint a token. The deny path
// returns STATUS_ACCOUNT_RESTRICTION. Otherwise we return STATUS_NOT_IMPLEMENTED
// to say "this package does not handle this logon". Note: a caller that selects
// this package by id and gets STATUS_NOT_IMPLEMENTED has its logon fail here; it
// is not transparently retried against MSV1_0. Standard network/batch logons do
// not address this package at all - that is what the sub-authentication filter
// (subauth.cpp) is for. See the article, "deny is easy, success is hard".
static NTSTATUS LogonUserEx2Impl(SECURITY_LOGON_TYPE LogonType, PVOID ProtocolSubmitBuffer,
PVOID ClientBufferBase, ULONG SubmitBufferSize, PNTSTATUS SubStatus)
{
const bool nonInteractive = IsNonInteractive(LogonType);
const bool enrolled = nonInteractive &&
IsEnrolledAccount(ProtocolSubmitBuffer, SubmitBufferSize, ClientBufferBase);
if (nonInteractive && enrolled)
{
ApLog(EVENTLOG_WARNING_TYPE, 201,
std::wstring(L"Denied a ") + LogonTypeName(LogonType) +
L" logon for an enrolled account. This path has no second factor.");
if (SubStatus) *SubStatus = STATUS_ACCOUNT_RESTRICTION;
return STATUS_ACCOUNT_RESTRICTION;
}
// Not "passed on": nothing is forwarded anywhere. The caller asked for this
// package, and this package never builds a token, so the logon fails.
ApLog(EVENTLOG_INFORMATION_TYPE, 202,
std::wstring(L"Declined a ") + LogonTypeName(LogonType) +
L" logon addressed to this package. It fails; this package never builds a token.");
return STATUS_NOT_IMPLEMENTED;
}
NTSTATUS NTAPI LsaApLogonUserEx2(
PLSA_CLIENT_REQUEST /*ClientRequest*/,
SECURITY_LOGON_TYPE LogonType,
PVOID ProtocolSubmitBuffer,
PVOID ClientBufferBase,
ULONG SubmitBufferSize,
PVOID* ProfileBuffer,
PULONG ProfileBufferSize,
PLUID /*LogonId*/,
PNTSTATUS SubStatus,
PLSA_TOKEN_INFORMATION_TYPE /*TokenInformationType*/,
PVOID* /*TokenInformation*/,
PUNICODE_STRING* AccountName,
PUNICODE_STRING* AuthenticatingAuthority,
PUNICODE_STRING* MachineName,
PSECPKG_PRIMARY_CRED /*PrimaryCredentials*/,
PSECPKG_SUPPLEMENTAL_CRED_ARRAY* CachedCredentials)
{
if (ProfileBuffer) *ProfileBuffer = nullptr;
if (ProfileBufferSize) *ProfileBufferSize = 0;
if (SubStatus) *SubStatus = STATUS_SUCCESS;
if (AccountName) *AccountName = nullptr;
if (AuthenticatingAuthority) *AuthenticatingAuthority = nullptr;
if (MachineName) *MachineName = nullptr;
if (CachedCredentials) *CachedCredentials = nullptr;
try
{
return LogonUserEx2Impl(LogonType, ProtocolSubmitBuffer, ClientBufferBase,
SubmitBufferSize, SubStatus);
}
catch (...)
{
if (SubStatus) *SubStatus = STATUS_INSUFFICIENT_RESOURCES;
return STATUS_INSUFFICIENT_RESOURCES;
}
}
static NTSTATUS CallStub(PVOID* ProtocolReturnBuffer, PULONG ReturnBufferLength,
PNTSTATUS ProtocolStatus)
{
if (ProtocolReturnBuffer) *ProtocolReturnBuffer = nullptr;
if (ReturnBufferLength) *ReturnBufferLength = 0;
if (ProtocolStatus) *ProtocolStatus = STATUS_NOT_IMPLEMENTED;
return STATUS_SUCCESS;
}
NTSTATUS NTAPI LsaApCallPackage(PLSA_CLIENT_REQUEST, PVOID, PVOID, ULONG,
PVOID* rb, PULONG rl, PNTSTATUS ps)
{ return CallStub(rb, rl, ps); }
NTSTATUS NTAPI LsaApCallPackageUntrusted(PLSA_CLIENT_REQUEST, PVOID, PVOID, ULONG,
PVOID* rb, PULONG rl, PNTSTATUS ps)
{ return CallStub(rb, rl, ps); }
NTSTATUS NTAPI LsaApCallPackagePassthrough(PLSA_CLIENT_REQUEST, PVOID, PVOID, ULONG,
PVOID* rb, PULONG rl, PNTSTATUS ps)
{ return CallStub(rb, rl, ps); }
VOID NTAPI LsaApLogonTerminated(PLUID /*LogonId*/)
{
}
Some things I want to point out:
- All the out parameters are set at the top, before any decision. If LSA reads an uninitialised pointer because some path forgot to set it, you do not get a clean failure, you get a crash in lsass.
ReadClientStringchecksoff > submitSizefirst and only thenus.Length > submitSize - off. The order matters. The other way around,submitSize - offwould underflow on an out-of-range offset and the check would pass.IsEnrolledAccountonly parses two message types. An unknown one returns false, which means "not enrolled", which means the logon is declined rather than denied. Unknown input has to fail in the harmless direction.AllocateLsaHeapis LSA's allocator from the dispatch table, notnew. The package name you hand back belongs to LSA afterwards. If the second allocation fails, the first one has to be freed withFreeLsaHeap, which is the kind of bookkeeping you normally would not bother with and here absolutely should.ApLogwrites toOutputDebugStringWfirst and the event log second. The event log is best effort. IfRegisterEventSourceWfails inside early boot, the logon must not care.- Every export catches every C++ exception.
ReadClientStringand the log lines usestd::wstring, and that can throwstd::bad_alloc. If an exception goes back into LSA, lsass goes down, and the machine with it. Now it becomesSTATUS_INSUFFICIENT_RESOURCES. The real work is inLogonUserEx2Impl, the export is just the safety net around it. - The log line for the decline says what really happens: "Declined ... It fails". My first version said "Passed a ... logon to the normal packages", which is exactly the misreading the paragraph above warns about.
TacAuthPackage.def:
LIBRARY TacAuthPackage
EXPORTS
LsaApInitializePackage
LsaApLogonUserEx2
LsaApCallPackage
LsaApCallPackageUntrusted
LsaApCallPackagePassthrough
LsaApLogonTerminated
The limit that pushes us one layer deeper
Here is the thing that makes the whole package above mostly an exercise. A top-level authentication package is only called for logons that address it by package id. A caller does LsaLookupAuthenticationPackage("TacAuthPackage") and then passes that id to LsaLogonUser. A normal SMB logon asks for MSV1_0, or for Negotiate. Nobody asks for us.
So by itself this package never even sees the network path it is supposed to guard. That is not a bug in the code, that is how the layer works. And it is exactly why real products do the fragile MSV1_0 wrap.
There is a cleaner answer.
The sub-authentication filter
MSV1_0 has a documented way to hook into its own logon processing. Actually two of them. I picked the wrong one in the first version of this article, so first both side by side:
| Sub-authentication routine | Sub-authentication filter | |
|---|---|---|
| Export | Msv1_0SubAuthenticationRoutine | Msv1_0SubAuthenticationFilter |
| Registered as | MSV1_0\Auth1 ... AuthN | MSV1_0\Auth0 |
| Called | only for logons that select package N | after MSV1_0 has validated a logon |
| The password check | is its own job | is already done by MSV1_0 |
Returning STATUS_SUCCESS means | "authenticated" | "no objection" |
My first version used the routine. I thought it only adds a decision and MSV1_0 still checks the password afterwards. Microsoft's documentation says the opposite: "When subauthentication is used, authentication is the responsibility of the subauthentication DLL." The routine gets the challenge-response and the password hashes so that it can check them. MSV1_0 just trusts the answer. My routine only read the name and returned STATUS_SUCCESS for everything except an enrolled network logon. So for a logon that selected it, this was an approval without any password check. A code audit found it. That's why there is the correction at the top.
For a second factor you want the filter. MSV1_0 does the password check, the account restrictions and the token, exactly as without us. The filter only gets to say "no". That is the property I claimed for the routine, and only the filter has it.
There is one catch, and I don't want to hide it. Microsoft documents Auth0 for the registry of a domain controller. Whether MSV1_0 also calls the filter for local accounts on a Windows 10/11 workstation, I could not check yet. Every call writes a [Doppio-SubAuth] line to the kernel debugger, so this is the first thing to check in your VM. If you never see a line, the filter is loaded but never asked. Then the user rights from the beginning are your only enforcement.
Why this one and not the authentication package
Side by side, with the tile from Part 1 in the first column for orientation:
| Credential provider | Authentication package | Sub-auth filter | |
|---|---|---|---|
| File | CTacCredential.cpp | ap.cpp | subauth.cpp |
| Runs in | LogonUI.exe | lsass.exe | lsass.exe |
| Registered under | Credential Providers | Authentication Packages | MSV1_0\Auth0 |
| Called by | Winlogon / LogonUI | LSA, if addressed by id | MSV1_0, after the password check |
| Sees interactive logon | yes | only if addressed | yes, if MSV1_0 calls it |
| Sees network logon | no | no | yes, if MSV1_0 calls it |
| Credential material | collects it, it has to | sits in the submit buffer | never touched, account id only |
| Must build a token | no | yes, on success | no |
| A bug costs you | one tile | the machine | the machine |
Four things make this the easier layer:
- The account comes from the SAM, not from the client. The filter gets
USER_ALL_INFORMATION, the account record that MSV1_0 read from the SAM. The RID in there (UserId) is the account, no matter how the client wrote the name. No pointer relocation, noReadClientString, and no problem with renames. - No token to build. We return a veto or no objection, and MSV1_0 produces the token, the profile and the group memberships.
- It runs after the password check. "No objection" can never let anything through that MSV1_0 refused. A bug here can at worst miss a veto. It can never accept a wrong password. That's exactly what you want from code you can't test on the bench.
- It never needs the credentials. The decision needs the account id, and that plus the name for the log is all we read. There is no point in this design where a password or a hash has to pass through our code.
One thing I do not want to blur, though. "The safest way to do this inside LSA" is not the same claim as "the safe way to do this". The user rights from the beginning of the article run no code in lsass at all and cannot take a machine down. This runs inside lsass. It is the right way to do it in LSA. It is not the low-risk way to harden a machine you care about.
subauth_filter.h:
#pragma once
//
// (Named subauth_filter.h, not subauth.h: with the project folder on the
// include path, a project header called subauth.h would hide the SDK header
// of the same name that this file includes.)
//
// Doppio MSV1_0 sub-authentication FILTER (SKELETON).
//
// MSV1_0 has two sub-authentication hooks, and they are very different:
//
// Msv1_0SubAuthenticationRoutine (Auth1..AuthN)
// Called only for logons whose request explicitly selects package N in
// ParameterControl - ordinary SMB/NTLM logons never do. And when it IS
// called, authentication is its job: MSV1_0 hands it the challenge
// response and the SAM hashes and trusts its answer. Returning
// STATUS_SUCCESS there approves the logon without any password check.
// That is the wrong seam for a veto, and this project no longer uses it.
//
// Msv1_0SubAuthenticationFilter (Auth0)
// Called AFTER MSV1_0 has validated the logon, as an additional check.
// STATUS_SUCCESS means "no objection, proceed", a failure status vetoes.
// MSV1_0 keeps the password check, the account restrictions and the
// token. This is the seam a second-factor veto belongs in.
//
// Policy of this skeleton: refuse a NETWORK logon for an enrolled account (no UI
// to type a code there), and raise no objection to anything else.
//
// The account is identified by the RID that MSV1_0 read from the SAM
// (USER_ALL_INFORMATION::UserId), not by the name the client sent. The RID
// survives a rename and cannot be spelled differently.
//
// It is registered under:
// HKLM\SYSTEM\CurrentControlSet\Control\Lsa\MSV1_0
// Auth0 (REG_SZ) = TacSubAuth
// Microsoft documents Auth0 for the domain controller's registry. Whether the
// filter also runs for local SAM accounts on a workstation is exactly what to
// verify in the test VM: every call writes a [Doppio-SubAuth] OutputDebugString
// line, readable under the kernel debugger.
//
// WARNING: runs in lsass.exe. A bug can leave the machine unbootable. Throwaway
// VM, snapshot, kernel debugger only.
#define SECURITY_WIN32
#include <windows.h>
#include <sspi.h>
#include <ntsecapi.h>
#include <subauth.h>
extern "C"
{
NTSTATUS NTAPI Msv1_0SubAuthenticationFilter(
NETLOGON_LOGON_INFO_CLASS LogonLevel,
PVOID LogonInformation,
ULONG Flags,
PUSER_ALL_INFORMATION UserAll,
PULONG WhichFields,
PULONG UserFlags,
PBOOLEAN Authoritative,
PLARGE_INTEGER LogoffTime,
PLARGE_INTEGER KickoffTime);
}
The header used to be called subauth.h. That's also the name of the SDK header it includes. If the compiler has the project folder on its include path, it finds our file instead of the system one, and you get a very confusing recursive include. Under MinGW that cost me a while. Now it has its own name.
subauth.cpp:
#include "subauth_filter.h"
#include "store.h"
#include <cstdio>
#pragma comment(lib, "advapi32.lib")
#ifndef STATUS_SUCCESS
#define STATUS_SUCCESS ((NTSTATUS)0x00000000L)
#endif
#ifndef STATUS_ACCOUNT_RESTRICTION
#define STATUS_ACCOUNT_RESTRICTION ((NTSTATUS)0xC000006EL)
#endif
// Everything in this file runs inside lsass.exe, on MSV1_0's logon path. It is
// written to allocate nothing on the heap: fixed stack buffers, no std::wstring.
// No allocation means no std::bad_alloc, and the export still has a catch-all
// in case a later edit adds one. A C++ exception that reaches LSA would take
// lsass, and with it the whole machine, down.
namespace
{
// The SAM account name, for the log only. Bounded like any other input.
void SamName(const USER_ALL_INFORMATION* ua, WCHAR (&out)[129])
{
out[0] = L'?';
out[1] = L'\0';
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;
memcpy(out, us.Buffer, cch * sizeof(WCHAR));
out[cch] = L'\0';
}
// Debug output for every decision; the event log only for refusals. The
// filter sees every MSV1_0 logon it is called for, so logging approvals to
// the Application log would flood it from inside lsass.
void Report(bool refused, ULONG rid, const WCHAR* name, bool isNetwork)
{
WCHAR text[256];
if (refused)
swprintf_s(text, L"Refused a network logon for enrolled account '%s' (RID %lu). "
L"No second factor on this path.", name, rid);
else
swprintf_s(text, L"No objection to a %s logon for '%s' (RID %lu).",
isNetwork ? L"network" : L"non-network", name, rid);
WCHAR debug[288];
swprintf_s(debug, L"[Doppio-SubAuth] %s\n", text);
OutputDebugStringW(debug);
if (!refused)
return;
HANDLE h = RegisterEventSourceW(nullptr, L"TheAdminCafe 2FA");
if (h)
{
LPCWSTR s[1] = { text };
ReportEventW(h, EVENTLOG_WARNING_TYPE, 0, 210, nullptr, 1, 0, s, nullptr);
DeregisterEventSource(h);
}
}
}
// MSV1_0 calls this AFTER it has validated the logon. We only add a veto;
// password check, account restrictions and token stay with MSV1_0.
//
// The output parameters are left as MSV1_0 passed them in, except WhichFields
// (nothing to write back to the SAM) and Authoritative on a refusal. In
// particular LogoffTime and KickoffTime are NOT overwritten: they carry the
// account's logon hours, and a filter has no business extending them.
//
// IMPORTANT: we read ONLY the RID and the account name. We never touch the
// password hashes in UserAll or the challenge-response in LogonInformation.
// Reading and keeping that credential material is exactly the theft pattern
// this project refuses to build - the decision never needs it.
NTSTATUS NTAPI Msv1_0SubAuthenticationFilter(
NETLOGON_LOGON_INFO_CLASS LogonLevel,
PVOID /*LogonInformation*/,
ULONG /*Flags*/,
PUSER_ALL_INFORMATION UserAll,
PULONG WhichFields,
PULONG /*UserFlags*/,
PBOOLEAN Authoritative,
PLARGE_INTEGER /*LogoffTime*/,
PLARGE_INTEGER /*KickoffTime*/)
{
if (WhichFields)
*WhichFields = 0;
const bool isNetwork = (LogonLevel == NetlogonNetworkInformation ||
LogonLevel == NetlogonNetworkTransitiveInformation);
try
{
// No SAM record, no decision: MSV1_0 has already validated the logon,
// so "no objection" does not let anything through that MSV1_0 refused.
if (!UserAll)
return STATUS_SUCCESS;
const ULONG rid = UserAll->UserId;
// A single registry read - no DPAPI, no LSA name/SID lookup - so
// nothing here re-enters LSA from inside MSV1_0.
const bool refuse = isNetwork && tac::IsEnrolledByRid(rid);
WCHAR name[129];
SamName(UserAll, name);
Report(refuse, rid, name, isNetwork);
if (refuse)
{
if (Authoritative)
*Authoritative = TRUE; // do not retry elsewhere
return STATUS_ACCOUNT_RESTRICTION;
}
return STATUS_SUCCESS;
}
catch (...)
{
// Should be unreachable (nothing above allocates). If it ever happens:
// fail closed for the path this filter exists for, and stay out of the
// way of everything else.
if (isNetwork)
{
if (Authoritative)
*Authoritative = TRUE;
return STATUS_ACCOUNT_RESTRICTION;
}
return STATUS_SUCCESS;
}
}
BOOL APIENTRY DllMain(HMODULE h, DWORD reason, LPVOID)
{
if (reason == DLL_PROCESS_ATTACH)
DisableThreadLibraryCalls(h);
return TRUE;
}
TacSubAuth.def:
LIBRARY TacSubAuth
EXPORTS
Msv1_0SubAuthenticationFilter
Some things I want to point out:
- No heap. Everything in the filter uses fixed buffers on the stack. The name is copied into 129
WCHARs, the log line is built withswprintf_s. Nostd::wstring, so nostd::bad_alloc. The export still has acatch (...), in case a later change adds something that throws. Then a network logon is refused and everything else gets no objection. An exception that reaches LSA would take lsass down, and the machine with it. - Only refusals go to the event log. The filter sees every logon MSV1_0 calls it for. An event for every approval, written from inside lsass, would flood the Application log. The debugger line is written for every call.
- We step over the credentials. For a network logon
LogonInformationpoints to the challenge and the response.UserAllhas the password hashes from the SAM, right next to the RID and the name we read. The filter reads neither. It doesn't even give theLogonInformationparameter a name. That one restraint is the line between a 2FA control and a password stealer. - The name is only for the log. It is cut to 128 characters, just so a corrupt length can't run past the buffer. The decision is made on the RID alone.
DisableThreadLibraryCallsinDllMain. Without it every thread lsass creates calls into our DLL for nothing.
The out-parameters
The pointers MSV1_0 hands in are not decoration. My first version set all of them at the top of the function. The filter now leaves them alone, with two exceptions.
WhichFields = 0 says we are not asking MSV1_0 to write back any of the account's fields. We could update things like the bad-password count. We deliberately don't, because the lockout counter from Part 1 lives with the credential provider and one owner is better than two.
Authoritative = TRUE is set only when we refuse. It tells MSV1_0 not to look for another opinion.
LogoffTime and KickoffTime are not touched anymore. They contain the logon hours of the account, as MSV1_0 calculated them. My first version set both to "never". That would have quietly extended every session past the logon hours the admin set. A veto has no business doing that. UserFlags also stays as MSV1_0 set it.
Which logons it sees
The routine from the first version had a limit that I described myself: the logon selects it. The ParameterControl field of the logon information carries the package number in its upper bits, and a normal SMB logon doesn't set them. So the routine never saw the logons it should gate. And the ones that did select it were approved without a password. Two good reasons why it was the wrong hook.
The filter is not selected by anybody. MSV1_0 calls it for the logons it handles, after its own checks. Two things follow from that:
- RDP with NLA. Network Level Authentication checks the password with a network logon (NTLM for a local account) before the session starts. For an enrolled account the filter refuses that, exactly like the deny right for network logons. So for RDP you either turn NLA off, then the session goes straight to the 2FA tile from Part 1, or you use the console.
- Batch and service. Those don't arrive as network logons, so the filter has no objection. That's what the user rights are for.
That's why the two halves of this article belong together. The filter is where the decision lives, and it's the part worth understanding and extending. The "Deny log on ..." user rights are where the enforcement lives. LSA checks them no matter which package a logon asks for, and they don't depend on MSV1_0 calling anything. Build the filter to learn the layer and to have a place for real logic. And keep the deny rights on.
What I could actually test
For the first version I loaded TacSubAuth.dll, built NETLOGON_* structures by hand and called the routine directly. All four results matched what I expected, including this one:
| Input | Result |
|---|---|
| non-enrolled account, network logon | STATUS_SUCCESS (defer) |
Looking back, that row was the bug. From a routine that is responsible for the password check, STATUS_SUCCESS means "authenticated". The test passed because it tested what I thought the return value means, not what MSV1_0 does with it. A test of your own logic can't tell you that you got the contract wrong.
For the filter I can say this much: it compiles for x64, it exports exactly Msv1_0SubAuthenticationFilter, and the compiled code doesn't allocate anything on the heap. The important part I can't stand behind yet: that MSV1_0 calls it on your build of Windows, for local accounts, that it survives Credential Guard and that a reboot comes back up. That is yours to check under a kernel debugger, in a VM you can roll back.
Build
Both LSA pieces are plain C++ with nothing beyond the Windows SDK behind them, and they build the same way as Part 1. You need the "x64 Native Tools Command Prompt" from Visual Studio or the Build Tools. A normal cmd or PowerShell does not work, because cl.exe and the SDK headers are not on the PATH there.
Option 1: build.bat
build.bat
That gives you all four binaries: TacProvider.dll and enroll.exe from Part 1, plus TacAuthPackage.dll and TacSubAuth.dll from this one.
Everything is built with /MT, so the C++ runtime is linked statically and the target VM needs no redistributable. In Part 1 that was convenience. Here it matters more: a DLL that LSA loads at boot and that then cannot find its runtime is a DLL that stops the machine from booting. And everything is built with /O2 /guard:cf /W4 /sdl, so Control Flow Guard is on in both DLLs that lsass loads.
Option 2: build-lsa.bat
While you iterate on the LSA code you do not want to rebuild the credential provider every pass:
build-lsa.bat
Same flags, just the two LSA DLLs:
cl /nologo /LD /MT /O2 /guard:cf /EHsc /std:c++17 /W4 /sdl /DUNICODE /D_UNICODE ^
ap.cpp store.cpp totp.cpp ^
/Fe:TacAuthPackage.dll ^
/link /guard:cf /DEF:TacAuthPackage.def ^
advapi32.lib crypt32.lib bcrypt.lib
cl /nologo /LD /MT /O2 /guard:cf /EHsc /std:c++17 /W4 /sdl /DUNICODE /D_UNICODE ^
subauth.cpp store.cpp totp.cpp ^
/Fe:TacSubAuth.dll ^
/link /guard:cf /DEF:TacSubAuth.def ^
advapi32.lib crypt32.lib bcrypt.lib
If you have not done it yet, run new-guids.ps1 once before any of this goes anywhere, so you are not using this repository's CLSIDs. That is a Part 1 concern but easy to forget when you come back.
Install
This is the part I would have found most confusing reading it back, so let me be blunt. The two ways from the chapter above turn into three independent things you can install, and they are not three steps. You do not install "the LSA half". You pick one, and two of the three only ever inside a VM you can throw away.
There is also no .reg file for any of it, unlike Part 1. That is on purpose. The authentication package has to be appended to a value that already has entries in it, and a .reg import replaces. Getting that wrong on Authentication Packages is exactly how you end up with a machine where nobody can authenticate at all. And Auth0 holds exactly one DLL, which may already belong to somebody else. So they are PowerShell scripts that read the current values, check they look sane, and only then add ours.
1. Snapshot
Before anything else. And again before every reboot that follows, not just this one.
2. Check RunAsPPL
This one will cost you an evening if you skip it. If RunAsPPL is on, lsass runs as a Protected Process Light and will not load an unsigned DLL into itself. Your package does not crash, does not error, does not log a thing. It simply is not there. You reboot, nothing happens, and then you debug code that was never given the chance to run.
reg query "HKLM\SYSTEM\CurrentControlSet\Control\Lsa" /v RunAsPPL
If that comes back 0x1 or 0x2, set it to 0 for the test and put it back when you are done. The install scripts check this too now and refuse to run while it is on. And notice what that step actually is. You just turned off a real hardening feature in order to load an unsigned module into lsass. On a throwaway VM that is fine. Anywhere else it is the trade you must never make.
3. The user rights, if you only want the hardening
.\deny-noninteractive.ps1 alice
.\deny-noninteractive.ps1 alice -KeepNetwork # keeps RDP with NLA, see below
.\deny-noninteractive.ps1 alice -Remove # undo
Sets "Deny log on" for network, batch and service on that account. The script only accepts a local user, not a group. If you type Users by mistake, it would otherwise deny all of this to every user on the machine. It takes effect at the next logon attempt, no reboot, and nothing of yours runs inside lsass.
The RDP right is left alone on purpose, because the tile from Part 1 shows up over RDP and can ask for a code there. But watch out for NLA. In the first version of this article I wrote that RDP keeps working. With NLA, which is on by default, it doesn't. NLA checks the password with a network logon before the session starts, and that is now denied. So you have two options. Turn NLA off, then RDP goes straight to the 2FA tile, with a bit more attack surface on port 3389. Or run the script with -KeepNetwork. Then RDP with NLA works, but SMB and WinRM still take the password alone for this account. There is no setting that gives you both.
The script exports the current policy first and merges the account's SID into each right, instead of writing the right from scratch. That detail matters. secedit /configure replaces a right's entire member list, so a naive version of this script would quietly strip every other account off SeDenyNetworkLogonRight on the way past.
Enroll the account in the tile before you deny its other paths, and keep a second admin that still works. A broken tile plus a denied network logon is a locked machine.
4. The authentication package
.\install-authpackage.ps1
Copies TacAuthPackage.dll into System32 and appends to:
HKLM\SYSTEM\CurrentControlSet\Control\Lsa
Authentication Packages = msv1_0 TacAuthPackage (REG_MULTI_SZ)
The script refuses to write at all if msv1_0 is not already in that list. That is a cheap guard against turning a typo into an unbootable machine. Reboot to load it, because LSA reads this value once, at boot.
Remember what this option actually buys you, from the chapter above: a package that is only ever called when a logon addresses it by id, which normal logons do not do. Load it to watch it load. Not because it closes anything.
5. The sub-authentication filter
.\install-subauth.ps1
Copies TacSubAuth.dll into System32 and registers it as the filter:
HKLM\SYSTEM\CurrentControlSet\Control\Lsa\MSV1_0
Auth0 (REG_SZ) = TacSubAuth
If Auth0 already belongs to another DLL, the script stops. If you installed the first version of this article, it also removes the old Auth1 to Auth4 entry of TacSubAuth. Then index the existing enrollments by RID, because that's what the filter reads, and reboot:
& "C:\Program Files\TheAdminCafe\enroll.exe" /reindex
This is the one worth loading. Steps 4 and 5 do not depend on each other, you can have either, both or neither registered.
6. Remove
uninstall-authpackage.ps1 and uninstall-subauth.ps1, each followed by a reboot.
Order matters on the way out: deregister, reboot, then delete the DLL from System32. Deleting the file while it is still registered is a good way to make LSA unhappy on the next boot.
Debugging and what to do if LSA will not start
You cannot attach Visual Studio to lsass. The way you debug this is a kernel debugger over the network to a second machine or the host. Both packages log every step with OutputDebugStringW, prefixed [Doppio-AP] and [Doppio-SubAuth], plus best-effort events in the Application log under source "TheAdminCafe 2FA".
If LSA does not start after a reboot:
- Restore the snapshot. This is why you took it.
- If you have no snapshot, boot WinRE from the install media, open a command prompt and load the offline hive:
reg load HKLM\OFF C:\Windows\System32\config\SYSTEM
reg query HKLM\OFF\ControlSet001\Control\Lsa /v "Authentication Packages"
Remove your DLL from the value, or delete the Auth0 value under ...\Lsa\MSV1_0, then reg unload HKLM\OFF and boot.
Two rules that keep this from ruining a machine:
- Never overwrite those values, only append or remove your own entry.
msv1_0must stay in the package list, or nobody on the machine can authenticate. - Change one thing per reboot. If you register both packages at once and the machine does not come back, you do not know which one did it.
Limits
The LSA part is real code, but it is a skeleton and I want to be precise about what it is not:
- Untested on a live LSA. Compiles, exports checked, nothing more.
- Microsoft documents the filter for domain controllers. Whether MSV1_0 calls it for local accounts on a workstation is untested.
- Only network logons are refused. Batch and service get no objection, so the user rights are still needed for those.
runas, the UAC credential prompt andLogonUserare interactive logons without the tile. Nothing in either part closes them.- The filter also blocks RDP with NLA for enrolled accounts, just like the deny right.
- The authentication package closes nothing in practice, because normal logons never address it. It still reads the name, which has the rename weakness. The filter uses the RID and doesn't.
- It will not load at all with
RunAsPPLon, because the DLLs are unsigned. The install scripts check this. - No lockout, no counters, no state on the LSA side. All of that lives with the credential provider from Part 1.
- A bug here does not fail a logon, it stops the machine from booting. There is no gentle failure mode at this layer.
What real products do differently
So how does AuthLite or Duo see every logon, including the ones neither our tile nor our sub-auth filter reliably catches? And no, the answer is not "they inject into lsass". That phrase gets thrown around a lot, so let me be precise, because the distinction is the whole point.
A product does not push code into a running lsass.exe. It drops a DLL in a registry key and LSA loads it itself at boot, as part of lsass, running as SYSTEM. That is a documented, supported extension point, the same kind we used above. The thing that actually injects into a live lsass, writing a package into its memory at runtime with debug rights like mimikatz misc::memssp, is the attacker's version. Same seam in the OS, two completely different doors. One is opened by configuration, the other is forced from outside.
The "see everything" power lives in the third extension point from the table at the start: the SSP/AP. An SSP sits in the Negotiate authentication flow, and LSA calls its SpAcceptCredentials with the credentials for logons across the board: network, interactive, service, the lot. That is exactly where a product hangs its second factor or its policy. The classic LSA proxy approach wraps MSV1_0 so the credentials route through the product's code first.
And this is the line I stopped at on purpose. That same SpAcceptCredentials seam, with the credentials in hand, is precisely what credential-stealing code abuses. Mimilib is an SSP whose SpAcceptCredentials just writes the plaintext to a file. The mechanism is identical to a legitimate wrapper. The only difference is the one line that keeps the password.
Doppio deliberately does not build that component, and the reason is not squeamishness. For a 2FA gate it adds nothing over what the sub-auth filter already does. The filter decides whether to refuse without ever needing the credential material. The wrapper's only marginal capability over what we have is sitting in the plaintext path, and that capability is a credential-interception primitive, not a second factor. If you want to study the SSP/AP boilerplate itself, read it at the source: Microsoft's Creating Custom Security Packages and LSA Mode Initialization.
Even if you do go the SSP route, three things make it hard on a modern system:
- PPL. You already met this in the install chapter. On a hardened machine lsass only loads a DLL that is properly signed for that protection level. An unsigned SSP is simply refused, which is also why "sign your own DLL" was on the checklist in Part 1, and why an unsigned module showing up in lsass is exactly what your EDR should scream about.
- Credential Guard. The secrets you might imagine reaching are not there to reach. Credential Guard isolates them in a VSM/LSAIso enclave that your SSP, running in ordinary lsass, cannot touch. It is a different trust boundary.
- A crash is a bugcheck. An exception in an SSP does not fail a logon, it takes lsass down, and a dead lsass is a reboot. In a loop, if it faults at startup.
Put together: the SSP/AP is how you would cover every logon type, it is the fragile and dangerous path, and its credential-facing core is indistinguishable from malware. That is the honest reason Doppio ends at a sub-authentication filter that only ever reads an account id and returns a veto.
I might still take the SSP/AP on in a Part 3, but done the way it should be: a properly signed, PPL-compatible package that only ever makes an enforcement decision, that works with Credential Guard instead of poking at it, and that never keeps a credential it was handed. The interesting part of that article would not be the hook, it would be everything around it. Signing for PPL, surviving a reboot, failing without a bugcheck. The plaintext-logging line that turns this seam into Mimilib stays out, in Part 3 as here.
Where this goes next
The skeleton stops at the decision, and that is a clean base to continue from. A few directions, if you want to take it further:
- Pair the sub-auth filter with the "Deny log on as a batch job" and "as a service" rights, so the paths it does not cover are covered anyway.
- Replace the flat deny with push approval, so a scheduled task or a remote logon can be approved from your phone. That needs a small service and an endpoint, a project of its own.
- Move the lockout counter and the secret into that service, so there is one policy across many machines instead of one per machine, and the secret can live in a TPM.
Across both parts we now have a 2FA for local Windows accounts that you can build yourself. A credential provider with a filter, replay protection, a lockout and logging in Part 1, and here the LSA half: an authentication package that teaches the layer, and a sub-authentication filter that refuses network logons for enrolled accounts. MSV1_0 still does the real credential work and the token. It is not a 2FA for every logon. runas and UAC still take the password alone, and whether the filter is called on your workstation is something you have to check in the debugger.
And you know exactly what each piece protects, what it does not, and the one line it refuses to cross. I got one of these pieces wrong the first time, and an audit found it. That's the second lesson of this article: in this layer it's not enough to test your own logic. You also need to have the contract right.
All of it is on GitHub as Doppio, MIT licensed. If you build on it, I'd like to hear about it.
Licensed under CC BY-NC-SA 4.0.
