bombWindows of Opportunity: exploiting race conditions in Seclogon to dump LSASS

TLDR

The Windows Secondary Logon service (seclogon) represents a critical component of the Windows authentication architecture, enabling users to execute processes under alternative security contexts without requiring full credential disclosure. However, this legitimate functionality harbors a sophisticated race condition vulnerability that can be exploited to bypass Process Protection Light (PPL) mechanisms and gain unauthorized access to the Local Security Authority Subsystem Service (LSASS) memory.

This research presents a comprehensive analysis of a race condition attack against the Seclogon service that leverages Thread Environment Block (TEB) manipulation, Opportunistic Locks (OpLocks), and system-wide handle enumeration to achieve privilege escalation and credential extraction. The attack demonstrates how seemingly benign service features can be chained together to circumvent modern Windows security controls, including those designed specifically to protect high-value targets like LSASS.

The vulnerability exploits the service's implicit trust in caller-provided process identification data during handle operations. By manipulating the calling thread's process identifier and extending the exploitation window through file locking mechanisms, an attacker can trick Seclogon into creating privileged handles to LSASS that can subsequently be duplicated and used for memory dumping operations.

This analysis covers the complete attack chain from initial reconnaissance through final credential extraction, providing detailed technical implementation guidance while addressing the defensive implications for security practitioners. The research highlights the sophisticated nature of modern Windows exploitation techniques and the importance of understanding complex inter-service interactions when assessing system security posture.

Understanding Process Protection Light

The Windows protection model emerged from digital rights management (DRM) requirements in Windows Vista and was originally designed to protect high-value media content through Protected Media Path architecture. Microsoft subsequently extended this framework into Process Protection Light to defend critical system components against administrator-level attacks, fundamentally altering the traditional Windows security paradigm where the debug privilege granted unrestricted process and memory access.

PPL protection operates through kernel-level enforcement mechanisms that modify standard process management behavior. The protection system stores protection metadata in a single-byte PS_PROTECTION union that encodes both protection type and signer hierarchy level through bitfields. This creates a strict access control model where processes can only access other processes at equal or lower protection levels, regardless of caller privileges.

The signer hierarchy establishes eight distinct protection levels, each corresponding to specific system roles and trust boundaries. The different levels are used to protect critical processes ranging from Memory Compression (0x72), Session Manager and Client Server Runtime (0x62).

Priority

Protection Level

Protection Type

Signer

1 (Highest)

PS_PROTECTED_SYSTEM (0X72)

Protected

WinSystem

2

PS_PROTECTED_WINTCB (0x62)

Protected

WinTcb

3

PS_PROTECTED_WINTCB_LIGHT (0x61)

Protected Light

WinTcb

4

PS_PROTECTED_WINDOWS (0x52)

Protected

Windows

5

PS_PROTECTED_WINDOWS_LIGHT (0x51)

Protected Light

Windows

6

PS_PROTECTED_LSA_LIGHT (0x41)

Protected Light

Lsa

7

PS_PROTECTED_ANTIMALWARE_LIGHT (0x31)

Protected Light

Anti-malware

8

PS_PROTECTED_AUTHENTICODE (0x21)

Protected

Anti-malware

9

PS_PROTECTED_AUTHENTICODE_LIGHT (0x11)

Protected Light

Authenticode

10 (Lowest)

PS_PROTECTED_NONE (0x00)

None

None

As shown in the table above, the LSASS process operates at the PS_PROTECTED_LSA_LIGHT protection level; this is not the highest protection level available but it blocks process and memory-related operations from non-PPL processes.

WinTcb refers to the "Windows Trusted Computing Base" - the collection of hardware, firmware, and software components that are critical to Windows security. Processes with WinTcb protection levels include core system components like Session Manager (smss.exe), Client Server Runtime (csrss.exe), Service Control Manager (services.exe), and Windows Logon (winlogon.exe). These processes form the foundational security infrastructure of Windows and receive higher protection levels than even critical processes like LSASS.

The protection mechanism integrates deeply with Code Integrity / Device Guard by enforcing signature-based restrictions on the DLLs that can be loaded into protected processes; this prevents protected processes from loading unsigned or inappropriately signed libraries, ensuring the entire address space maintains consistent trust levels. This prevents attackers from compromising PPL processes through library injection or replacement attacks. Moreover, certificate-based authentication enables the protection system through enhanced key usage extensions embedded in digital code signing certificates. Microsoft controls two specific EKU OIDs (1.3.6.1.4.1.311.10.3.22 and 1.3.6.4.1.311.10.3.20) that, combined with hardcoded signer strings and additional EKUs, determine protection eligibility. The Windows System Component Verification EKU (1.3.6.1.4.1.311.10.3.6) specifically enables WinTcb protection levels.

The minimum TCB enforcement mechanism guarantees protection for critical system components regardless of caller specifications. Core system binaries including Session Manager, Client Server Runtime, Service Control Manager, and Windows Initialization automatically receive WinTcb-Light protection when launched from system directories. This prevents even administrative processes from spawning unprotected versions of essential system components, maintaining security boundary integrity throughout the boot process.

Secondary Logon service architecture and exploitation primitives

The Windows Secondary Logon service (seclogon) exposes a single primary RPC function: SeclCreateProcessWithLogonW. This service bridges user-mode process creation requests with alternative credentials, implementing core logic in SlrCreateProcessWithLogon that interfaces with CreateProcessWithLogonW and CreateProcessWithTokenW APIs.

The service's critical vulnerability lies in its trust of user-provided process identification data: when processing requests, seclogon calls OpenProcess on client-provided PIDs with access rights PROCESS_QUERY_INFORMATION | PROCESS_CREATE_PROCESS | PROCESS_DUP_HANDLE. This handle enables PPID spoofing operations but is closed shortly after use, creating an exploitable race condition window.

Below is a decompiled version of the SlrCreateProcessWithLogon function found in seclogon.dll that highlights the aforementioned flaw.

The service's architecture contains an additional vulnerability in the SlpSetStdHandles function that compounds the process ID spoofing attack. When attackers set the STARTF_USESTDHANDLES flag in their STARTUPINFO structure and specify handle values in the standard stream fields (hStdInput, hStdOutput, hStdError), the service attempts to duplicate these handles from what it believes is the calling process into the newly created process. However, because the service has been tricked through PID spoofing into opening a handle to LSASS instead of the actual caller, SlpSetStdHandles will duplicate handles from within LSASS's handle table rather than the legitimate calling process, effectively leaking privileged handles to the attacker's newly created process.

In the SlrCreateProcessWithLogon function, right after a new handle to the target process is opened with PROCESS_DUP_HANDLE permissions, there is a check to see if the STARTF_USESTDHANDLES is specified and - if it is - a new duplicate handle is created and its input, output and error streams are set up using the SlpSetStdHandles function.

This mechanism works because SlpSetStdHandles performs handle duplication operations using DuplicateHandle, treating the spoofed process handle as a legitimate source. The function doesn't independently verify that the source process is actually the caller - it simply trusts the handle that was opened earlier in the process, which has already been compromised through PID spoofing via TEB manipulation.

Here is what the beautified decompiled code for SlpSetStdHandles looks like:

These architectural flaws create multiple exploitation primitives such as:

  • process ID spoofing via TEB manipulation

  • handle enumeration through NtQuerySystemInformation

  • process cloning via NtCreateProcessEx

  • credential extraction through MiniDumpWriteDump (or other custom dumping functions) without direct LSASS interaction

This enables sophisticated attacks that bypass traditional monitoring focused on direct LSASS access. Here is what the whole process would look like:

Race condition mechanics and technical implementation

As we just discussed, the seclogon race condition exploits precise timing vulnerabilities in the service's process creation workflow; to achieve a higher chance of success, we can extend the race window by placing an Opportunistic Lock (OpLock) on a file the service accesses when creating a new process.

TEB Manipulation

The process spoofing part of the attack is relatively simple: the Thread Environment Block (TEB) contains thread-specific information including the ClientId field. This structure stores both process and thread identifiers that seclogon trusts and uses when determining the caller identity.

Spoofing the caller's PID is as simple as modifying the structure's UniqueProcess field, replacing it with LSASS's process ID. Here is a simple function to retrieve the caller process' TEB structure and modify its ClientId structure.

Here is the result when the executable is ran.

This will work on seclogon as well since the underlying mechanism reads the process ID from the TEB, the same field we're spoofing; by debugging a process that uses GetCurrentProcessId we can see the disassembly for the function which behaves like our function by first reading from the process ID from the TEB (GS segment + 0x30 + 0x40).

So when the service queries the calling process information, it reads the spoofed value from the TEB and opens handles to LSASS instead of the actual caller process, thus redirecting all subsequent handle operations towards the protected process.

OpLock

Now that we know how to get a legitimate Windows service to believe we are calling its functions from a privileged process, we need a way to extend the time window in which the duplicate handle to LSASS stays alive in seclogon's handle table.

Fortunately, the CreateProcessWithLogonW function can take an application name in its lpApplicationName parameter that can be used to specify which executable will be run on behalf of the user whose credentials were specified in the lpUsername, lpDomain and lpPassword parameters.

So if the file we want the ask the service to access is locked, it will need to wait until the resource is available again before starting the process. This detail can be used to extend the time between the creation of the duplicated handle to LSASS by SlrCreateProcessWithLogon and the handle being closed.

We can use something like Procmon to see the specified file being accessed by CreateProcessWithLogonW. The following code specified fake user credentials and tries to start notepad with them.

If we compile and run the program we see that the process is successfully created without delays and the file is being accessed.

Now let's try to set an OpLock on the same file using an external utility (see code below).

Executing the program will set an opportunistic lock on the C:\Windows\System32\notepad.exe file. It's worth noting that the utility implements both Opportunistic Locks and Exclusive Locks. Normally they both serve the same purpose but these two types of locks differ greatly in behavior: while OpLocks work asynchronously, Exclusive Locks are a synchronous mechanism, meaning that Exclusive Locks cannot be used to extend the race window condition as any attempt to access an exclusively-locked file will fail immediately. OpLocks, on the other hand, allow the file access attempt to hang indefinitely, giving us complete control over how long the seclogon service should wait for.

This might seem as a small distinction but it's a critical detail in our attack chain.

So if we now copy notepad.exe to a different folder (the original one is owned by TrustedInstaller so we cannot set an OpLock on it even as administrator without modifying the permissions), set an Opportunistic Lock on it and try to run our PoC we see it hang indefinitely until we release the lock (in this case about 40 seconds).

In the context of this technique, we don't necessarily need a binary file, any file we can set a lock on will do. Since the operational lock comes into play before CreateProcessWithLogonW has a chance of actually accessing the file, the targeted file is never mapped into memory like a normal PE would, meaning it doesn't have to be a PE at all!

One of my tests was performed by setting the lock on a newly created text file with some dummy contents and the technique worked just as well. The final PoC I will show in this post uses a text file (C:\Users\otter\Desktop\test.txt) as target file.

So which files can we target exactly?

Since the only requirement is that we can set an OpLock on the target file, we just need to figure out which files we can set these on.

Microsoft is very clear about this matter in its documentation, specifically in the Requesting and Granting Oplocksarrow-up-right page: the oplock we are requesting in this case is a OPLOCK_LEVEL_CACHE_READ | OPLOCK_LEVEL_CACHE_HANDLE type, which maps to the Read-Handle type in Microsoft's terminology so we can reference that table to understand when that lock can be set.

The first parameter is the access mode: the stream is opened for ASYNCHRONOUS access; if opened for SYNCHRONOUS access, STATUS_OPLOCK_NOT_GRANTED is returned. This requirement is already handled by our code by passing the FILE_FLAG_OVERLAPPED flag in the CreateFileW call.

The second requirement is transactional state: there have to be no TxF transactions active on the file. This should not be the case for normal files such as notepad.exe or our test.txt so we can somewhat ignore this.

The third requirement is range locks: there are no current Byte Range Locks on the stream.

Fourth is memory-mapped sections: there have to be no writable user-mapped sections on the stream. The REQUEST_OPLOCK_OUTPUT_BUFFER.Flags field will have the REQUEST_OPLOCK_OUTPUT_FLAG_WRITABLE_SECTION_PRESENT flag set.

Now the most relevant part: the existing oplock state. The table in the MSDN specifies what happens depending on what's already held on the stream; for Read-Handle requests, the only success paths are "No oplock" or "Read".

This explains why we either need to use a file that has been just created and has no open handles to it, or copy a legitimate Windows executable from the original directory to a new one: any pre-existing opens that hold a conflicting oplock type or that simply represent another open on the stream without a matching oplock key will cause the Read-Handle request to fail.

Handle Enumeration

Now that we successfully put the Seclogon service "on hold" we know are able to enumerate all the handles present in the process' handle table. If we examine the process with System Informer we'll see that the service has actually opened a process to lsass.exe with the right permissions (PROCESS_QUERY_INFORMATION | PROCESS_CREATE_PROCESS | PROCESS_DUP_HANDLE) on our behalf.

Attaching a debugger to the seclogon process we also see that there is only 1 handle with ObjectTypeIndex of Process and it's pointing to the process with PID 1492 which - in this case - belongs to lsass.exe.

By enabling SeDebugPrivilege and SeImpersonatePrivilege we can modify our existing PoC to perform the following actions:

  1. Spoof LSASS's PID

  2. Set an OpLock on the C:\Users\otter\Desktop\test.txt file (the same one mentioned in the OpLock section of this post)

  3. Force Seclogon to open a privileged handle to LSASS, this handle will be kept open by the Opportunistic Lock

  4. Access Seclogon's memory directly to enumerate all handles of type Process

This part of the code will not be shared in full for brevity's sake since it consists in routine memory operations. The goal is to retrieve all object types using a system call such as NtQueryObject and parse the list to find all handles of type Process.

If we implemented the logic correctly and the Seclogon process is still waiting for the locked file to free up, we should see at least a handle to lsass.exe with the aforementioned privileges.

Looking closely at the output, you might spot something interesting:

Why do we have to look for the right object type twice? This is because the global handle table present in the Windows kernel only supports and contains numerical indexes to differentiate the object type. The following is a simplified example.

The ObjectTypeIndex is not fixed and varies between Windows versions, this means that we cannot just look for the "Process" string. To get around this we first have to discover what each index corresponds to; as mentioned previously we can do this by first calling NtQueryObject with its ObjectInformationClass argument set to ObjectTypesInformation to retrieve information about all objects.

According to Geoff Chappell's websitearrow-up-right, this is what the output will look like.

Offset (x86)

Offset (x64)

Definition

Versions

0x00

0x00

UNICODE_STRING TypeName;

all

0x08

0x10

ULONG TotalNumberOfObjects;

3.50 and higher

0x0C

0x14

ULONG TotalNumberOfHandles;

3.50 and higher

0x10

0x18

ULONG TotalPagedPoolUsage;

3.50 and higher

0x14

0x1C

ULONG TotalNonPagedPoolUsage;

3.50 and higher

0x18

0x20

ULONG TotalNamePoolUsage;

3.50 and higher

0x1C

0x24

ULONG TotalHandleTableUsage;

3.50 and higher

0x20

0x28

ULONG HighWaterNumberOfObjects;

3.50 and higher

0x24

0x2C

ULONG HighWaterNumberOfHandles;

3.50 and higher

0x28

0x30

ULONG HighWaterPagedPoolUsage;

3.50 and higher

0x2C

0x34

ULONG HighWaterNonPagedPoolUsage;

3.50 and higher

0x30

0x38

ULONG HighWaterNamePoolUsage;

3.50 and higher

0x34

0x3C

ULONG HighWaterHandleTableUsage;

3.50 and higher

0x38

0x40

ULONG InvalidAttributes;

3.50 and higher

0x3C

0x44

GENERIC_MAPPING GenericMapping;

3.50 and higher

0x4C

0x54

ULONG ValidAccessMask;

3.50 and higher

0x50

0x58

BOOLEAN SecurityRequired;

3.50 and higher

0x51

0x59

BOOLEAN MaintainHandleCount;

3.50 and higher

0x52

0x5A

UCHAR TypeIndex;

6.2 and higher

0x54

0x5C

ULONG PoolType;

3.50 and higher

0x58

0x60

ULONG DefaultPagedPoolCharge;

3.50 and higher

0x5C

0x64

ULONG DefaultNonPagedPoolCharge;

3.50 and higher

So to find the index we can just iterate over the output buffer, check if the TypeName is equal to "Process" and return the corresponding TypeIndex.

Mind that since the first two indexes (0 and 1) are reserved by the kernel, the first actual valid index value for an object type is 2, meaning that we have to add 2 to whatever index value we get a hit on.

Now that we know the index of the Process object type we can fetch all the handles from the system-wide handle table and filter them using the Process object type, target process and owning PID to - hopefully - find that only process handle open in Seclogon pointing to lsass.exe.

Process Cloning

If everything went well we should have a handle to LSASS belonging to the Seclogon process with permissions PROCESS_DUP_HANDLE | PROCESS_CREATE_PROCESS | PROCESS_QUERY_INFORMATION | PROCESS_QUERY_LIMITED_INFORMATION.

To successfully access the handle to lsass.exe, we first have to open an additional handle in the owner process with handle duplication privileges to duplicate the leaked handle.

With this duplicated and privileged handle to lsass.exe we can use NtCreateProcessEx (or similar functions) to fork the process, effectively creating a complete copy of the LSASS process, including all of its memory areas.

If we prevent our PoC from exiting and open System Informer we'll see there are now 2 lsass.exe processes: the parent process is the original one while the child process is the forked one.

Moreover, our lsass_race_condition.exe process contains a handle to the forked lsass.exe process with Full control privileges, allowing us to read and write memory in the forked process just like we would with the original one, minus all the scrutiny from security solutions.

Profit

At this point we're free to do whatever we want with the forked process as long as its handle remains intact and does not get closed.

The following is the full output of the PoC that uses a custom function similar to MiniDumpWriteDump to read LSASS's memory and format it in a file that can then be parsed by mimikatz or similar tools.

Further Research

While this research focused on leveraging the Seclogon race condition for LSASS credential extraction, the underlying primitives (TEB manipulation, OpLock-based timing control, and system-wide handle enumeration) open up several avenues for further investigation. However, it's important to understand the actual boundaries of this technique before speculating about extended use cases.

Why LSASS

The handle to LSASS is not some pre-existing handle that happens to be sitting inside the Seclogon process. It exists because Seclogon itself opens it during the CreateProcessWithLogonW codepath. When Seclogon receives the RPC call, it reads the caller's PID (which we've spoofed via TEB ClientId manipulation) and opens a process handle to that PID as part of its internal token duplication and process attribute setup logic. The OpLock stall extends the window during which this handle remains open inside Seclogon, giving us time to enumerate and duplicate it via NtQuerySystemInformation.

This means the technique is fundamentally constrained by two factors: what PID we spoof into the TEB, and whether Seclogon can successfully call OpenProcess against that target.

Targeting other PPL-protected processes

Since the TEB spoofing controls which PID Seclogon believes is calling it, there's nothing preventing us from spoofing the PID of a process other than LSASS, an EDR service for instance. The question then becomes: can Seclogon's svchost (which runs as an unprotected SYSTEM process) successfully open a handle to a process running under a different PPL signer level?

Referring back to the protection level hierarchy, LSASS runs at PS_PROTECTED_LSA_LIGHT (rank 6), while most EDR products run at PS_PROTECTED_ANTIMALWARE_LIGHT (rank 7). PPL enforcement doesn't blanket-deny all cross-protection OpenProcess calls, rather it filters the granted access rights based on the caller's protection level relative to the target's. The specific access mask Seclogon requests (0x14C0) is evidently permitted against PS_PROTECTED_LSA_LIGHT when the caller is running as SYSTEM, but whether the same mask would be granted against an AM-PPL process at a different signer level is untested. The kernel may filter the access down to a subset that excludes PROCESS_DUP_HANDLE or PROCESS_CREATE_PROCESS, which would make the resulting handle useless for this technique.

Constraints on handle types

It's also worth noting that the handles we obtain through this technique are limited to whatever Seclogon opens internally during the race window. In practice, this means process handles only, Seclogon has no reason to open thread handles, token handles, or other object types to the "caller" process during CreateProcessWithLogonW. Claims that this primitive could be extended to thread hijacking via duplicated thread handles don't hold up; you can only duplicate handle types that actually exist inside the Seclogon process, and those are determined entirely by Seclogon's own code, not by the attacker.

That said, the process handle obtained does carry PROCESS_CREATE_PROCESS rights, which is what enables the forking primitive demonstrated in this research. Whether those same rights could be leveraged for other operations against a non-LSASS target - such as spawning a child process that inherits a security context from a protected parent - remains an open question worth investigating.

References

Last updated