adPEAS v2 Episode 8: PAC Deep-Dive & Ticket Forging - What's Inside the Ticket
Deep dive into the PAC structure, NDR serialization, PAC checksums, and how adPEAS v2 forges Golden, Silver, and Diamond Tickets step by step.
Introduction
In Episode 7, we looked at how Kerberos messages are structured, how encryption works, and what lives inside the EncTicketPart. Along the way, we discovered a field at the end: authorization-data with ad-type 128 — the PAC. And that’s where things get really interesting.
The PAC (Privilege Attribute Certificate) is the reason Golden, Silver, and Diamond Tickets work. It contains the user’s identity information — groups, SIDs, privileges. And it’s the part we manipulate during Ticket Forging.
Prerequisites: You should have read Episode 7. You’ll need an understanding of ASN.1 DER, the EncTicketPart structure, and how AES-CTS/RC4-HMAC encryption works.
Part 1: PAC Architecture
What is the PAC?
The PAC is a Microsoft extension to the Kerberos protocol (MS-PAC). Pure MIT Kerberos has no PAC. In Active Directory, the PAC contains the Windows-specific authorization data for a user — everything the target service needs to make access control decisions.
EncTicketPart
+-- authorization-data [10]
+-- AD-IF-RELEVANT (type 1)
+-- AD-WIN2K-PAC (type 128)
+-- PACTYPE (the actual PAC)
|-- Buffer 1: LOGON_INFO
|-- Buffer 2: CLIENT_INFO
|-- Buffer 3: UPN_DNS_INFO
|-- Buffer 4: PAC_ATTRIBUTES
|-- Buffer 5: PAC_REQUESTOR
|-- Buffer 6: SERVER_CHECKSUM
+-- Buffer 7: KDC_CHECKSUM
PACTYPE Structure
The PAC is NOT ASN.1 — it’s a proprietary Microsoft format:
PACTYPE Header:
+----------------------------------------------------+
| cBuffers (4 Bytes) : Number of PAC buffers |
| Version (4 Bytes) : 0x00000000 |
+----------------------------------------------------+
| PAC_INFO_BUFFER[0]: |
| ulType (4 Bytes) : Buffer type (1, 6, 7, etc.) |
| cbBufferSize (4 Bytes) : Size of the buffer |
| Offset (8 Bytes) : Offset in the PAC blob |
| |
| PAC_INFO_BUFFER[1]: |
| ulType (4 Bytes) : ... |
| cbBufferSize (4 Bytes) : ... |
| Offset (8 Bytes) : ... |
| |
| ... (repeated for each buffer) |
+----------------------------------------------------+
| Buffer data (at the respective offsets): |
| [LOGON_INFO Bytes] |
| [CLIENT_INFO Bytes] |
| [UPN_DNS_INFO Bytes] |
| [PAC_ATTRIBUTES Bytes] |
| [PAC_REQUESTOR Bytes] |
| [SERVER_CHECKSUM Bytes] |
| [KDC_CHECKSUM Bytes] |
+----------------------------------------------------+
Each buffer is aligned to 8 bytes. The order of buffers in the PAC_INFO_BUFFER table can vary; the offsets point to the correct data.
The 7 Required PAC Buffers
Since the CVE-2021-42287 patches, Windows Server 2022+ enforces exactly these buffers:
| Type | Name | Description | Size |
|---|---|---|---|
| 1 | LOGON_INFO | User identity, groups, domain SID | ~500-2000 Bytes |
| 6 | SERVER_CHECKSUM | HMAC signature over the entire PAC | 20-28 Bytes |
| 7 | KDC_CHECKSUM | HMAC signature over the server checksum | 20-28 Bytes |
| 10 | CLIENT_INFO | Client name + auth timestamp | ~30-60 Bytes |
| 12 | UPN_DNS_INFO | UPN, DNS domain, SamName, SID | ~100-200 Bytes |
| 17 | PAC_ATTRIBUTES | Flags (PAC_WAS_REQUESTED) | 8 Bytes |
| 18 | PAC_REQUESTOR | User SID (raw) | ~28 Bytes |
An additional buffer generated by the KDC during PKINIT authentication:
| Type | Name | Description | Size |
|---|---|---|---|
| 2 | CREDENTIAL_INFO | Encrypted NTLM credentials (PKINIT only) | ~100-200 Bytes |
This buffer contains the user’s NT hash, encrypted with the DH-derived AS-REP Reply Key (KeyUsage 16). It is only included during PKINIT authentication because the client does not possess a symmetric key. adPEAS uses this for UnPAC-the-Hash — automatic NT hash recovery after PKINIT (see Episode 3).
Two additional buffers that the KDC normally generates, but which must not be included during Ticket Forging:
| Type | Name | Why leave them out? |
|---|---|---|
| 16 | TICKET_CHECKSUM | Only computable by the KDC (requires the krbtgt key of the respective TGS) |
| 19 | FULL_PAC_CHECKSUM | Only computable by the KDC |
adPEAS’ Build-PAC doesn’t include these buffers at all. If they were present (e.g., during in-place PAC patching), the KDC would reject the ticket because it cannot validate the checksums.
Part 2: LOGON_INFO — The User Identity (NDR Format)
Why NDR?
The LOGON_INFO buffer contains a KERB_VALIDATION_INFO structure. This is serialized in the NDR (Network Data Representation) format — not ASN.1, not JSON, not Protocol Buffers, but the serialization format from DCE/RPC dating back to the 90s.
NDR is… special. It has:
- Referent IDs for pointers
- Padding rules that depend on the position within the buffer
- Strings as RPC_UNICODE_STRING with separate referents
- Conformant arrays with a prepended MaxCount
NDR Type Serialization Header
Every NDR-serialized type in a PAC buffer starts with a header:
NDR Header (20 Bytes):
+------------------------------------------------------+
| Common Header (8 Bytes): |
| Version (1 Byte) : 0x01 |
| Endianness (1 Byte) : 0x10 (Little-Endian) |
| CommonHeaderLength (2 Bytes) : 0x0008 |
| Filler (4 Bytes) : 0xCCCCCCCC |
| |
| Private Header (8 Bytes): |
| ObjectBufferLength (4 Bytes) : Length of payload |
| Filler (4 Bytes) : 0x00000000 |
| |
| Top-Level Referent ID (4 Bytes): |
| 0x00020000 |
+------------------------------------------------------+
KERB_VALIDATION_INFO Fields
The actual payload after the NDR header:
KERB_VALIDATION_INFO (simplified):
+--------------------------------------------------------+
| Fixed-Size Part (~160 Bytes): |
| |
| LogonTime (8 Bytes) : FILETIME |
| LogoffTime (8 Bytes) : FILETIME |
| KickOffTime (8 Bytes) : FILETIME |
| PasswordLastSet (8 Bytes) : FILETIME |
| PasswordCanChange (8 Bytes) : FILETIME |
| PasswordMustChange (8 Bytes) : FILETIME |
| EffectiveName (8 Bytes) : RPC_UNICODE_STRING* |
| FullName (8 Bytes) : RPC_UNICODE_STRING* |
| LogonScript (8 Bytes) : RPC_UNICODE_STRING* |
| ProfilePath (8 Bytes) : RPC_UNICODE_STRING* |
| HomeDirectory (8 Bytes) : RPC_UNICODE_STRING* |
| HomeDirectoryDrive (8 Bytes) : RPC_UNICODE_STRING* |
| LogonCount (2 Bytes) : USHORT |
| BadPasswordCount (2 Bytes) : USHORT |
| UserId (4 Bytes) : RID (e.g. 500) |
| PrimaryGroupId (4 Bytes) : RID (e.g. 513) |
| GroupCount (4 Bytes) : Number of groups |
| GroupIds (4 Bytes) : Pointer -> Array |
| UserFlags (4 Bytes) : Flags |
| UserSessionKey (16 Bytes) : Zeroes |
| LogonServer (8 Bytes) : RPC_UNICODE_STRING* |
| LogonDomainName (8 Bytes) : RPC_UNICODE_STRING* |
| LogonDomainId (4 Bytes) : Pointer -> SID |
| ... |
| SidCount (4 Bytes) : Number of ExtraSIDs |
| ExtraSids (4 Bytes) : Pointer -> Array |
| ... |
| ResourceGroupCount (4 Bytes) : Number of ResGroups |
| ResourceGroupIds (4 Bytes) : Pointer -> Array |
| |
+--------------------------------------------------------+
| Referent Data (variable size): |
| |
| (Referents appear SEQUENTIALLY in the order |
| their pointers were declared!) |
| |
| Referent 0x00020004: EffectiveName String |
| MaxCount(4) + Offset(4) + ActualCount(4) |
| + UTF-16LE Bytes + Padding to 4 Bytes |
| |
| Referent 0x00020008: FullName String |
| ... (same structure) |
| |
| Referent 0x0002000C: LogonScript String |
| ... (empty string: MaxCount=0, Offset=0, Count=0) |
| |
| ... (more string referents) |
| |
| GroupIds Array: |
| MaxCount(4): Number of groups |
| GROUP_MEMBERSHIP[0]: |
| RelativeId(4): 512 (Domain Admins) |
| Attributes(4): 0x07 (Mandatory|Enabled|Default) |
| GROUP_MEMBERSHIP[1]: |
| RelativeId(4): 513 (Domain Users) |
| Attributes(4): 0x07 |
| ... |
| |
| LogonDomainId SID: |
| MaxCount(4): SubAuthCount |
| Revision(1): 1 |
| SubAuthCount(1): 4 |
| Authority(6): 0x000000000005 (NT Authority) |
| SubAuth[0..3]: Domain SID RIDs |
| |
| ExtraSids Array (if SidCount > 0): |
| MaxCount(4): Number of ExtraSIDs |
| KERB_SID_AND_ATTRIBUTES[0]: |
| SidPointer(4): Referent ID |
| Attributes(4): 0x07 |
| SidPointer -> SID Referent: |
| MaxCount(4) + SID Bytes |
| |
+--------------------------------------------------------+
The Golden Rule of NDR
Referents appear SEQUENTIALLY in the order their pointers were declared!
This means: You CANNOT search for referent IDs or do pattern matching. You must parse the structure linearly from the beginning and read each referent in exactly the order the pointers were declared in the fixed-size part.
Pointer IDs start at 0x00020004:
EffectiveName -> 0x00020004 (first pointer after top-level)
FullName -> 0x00020008
LogonScript -> 0x0002000C
ProfilePath -> 0x00020010
...
GroupIds -> 0x00020024
LogonDomainId -> 0x00020028
ExtraSids -> 0x0002002C (if present)
RPC_UNICODE_STRING Referent
Strings in NDR are… involved:
In the Fixed-Size Part (8 Bytes):
Length (2 Bytes) : Byte length of the string (without null terminator)
MaximumLength(2 Bytes) : Buffer size (= Length or Length + 2)
Pointer (4 Bytes) : Referent ID (or 0x00000000 if NULL)
In the Referent Part (variable):
MaxCount (4 Bytes) : MaximumLength / 2 (char count)
Offset (4 Bytes) : 0 (always)
ActualCount (4 Bytes) : Length / 2 (actual chars)
Data (n Bytes) : UTF-16LE encoded
Padding (0-3 Bytes): Alignment to 4 bytes
Example: The string “Administrator” (26 UTF-16LE bytes):
Fixed: 1A 00 1C 00 04 00 02 00
^^^^ ^^^^
26 Bytes Referent ID
Referent: 0E 00 00 00 <- MaxCount = 14 chars
00 00 00 00 <- Offset = 0
0D 00 00 00 <- ActualCount = 13 chars
41 00 64 00 6D 00 69 00 6E 00 69 00 <- "Admini"
73 00 74 00 72 00 61 00 74 00 6F 00 <- "strato"
72 00 <- "r"
00 00 <- Padding (2 bytes -> 4 aligned)
Part 3: The Other PAC Buffers
PAC_CREDENTIAL_INFO (Type 2)
This buffer is special — it only exists during PKINIT authentication:
PAC_CREDENTIAL_INFO:
+----------------------------------------------+
| Version (4 Bytes) : 0x00000000 |
| EncryptionType (4 Bytes) : e.g. 18 (AES256) |
| SerializedData (rest) : Encrypted |
+----------------------------------------------+
The SerializedData is encrypted with the AS-REP Reply Key (DH-derived) using KeyUsage 16. After decryption, it contains an NDR-serialized PAC_CREDENTIAL_DATA:
PAC_CREDENTIAL_DATA (after decryption):
+-------------------------------------------------+
| NDR Type Serialization 1 Header (16 Bytes) |
| Top-Level Referent (4 Bytes) |
| CredentialCount (4 Bytes) : Number of creds |
| Conformant Array MaxCount (4 Bytes) |
| |
| SECPKG_SUPPLEMENTAL_CRED[0]: |
| PackageName (RPC_UNICODE_STRING) : "NTLM" |
| CredentialSize (4 Bytes) |
| Credentials (Pointer -> data) |
| |
| Referent data: |
| PackageName String: "NTLM" (UTF-16LE) |
| NTLM_SUPPLEMENTAL_CREDENTIAL: |
| Version (4 Bytes) : 0 |
| Flags (4 Bytes) : 3 (LM+NT) |
| LmPassword (16 Bytes) |
| NtPassword (16 Bytes) <- THE NT HASH |
+-------------------------------------------------+
Why only with PKINIT? With symmetric auth (password/hash/key), the client already possesses a key derived from the password. With PKINIT, the client authenticates asymmetrically — it has no symmetric key. The KDC therefore delivers the NTLM credentials as a “bonus”, encrypted with the DH key that only the client and KDC know.
adPEAS uses this for automatic NT hash recovery via a U2U (User-to-User) TGS-REQ to itself. Details in Episode 3 (Authentication Deep-Dive, section “UnPAC-the-Hash”).
CLIENT_INFO (Type 10)
The simplest buffer — and the most treacherous:
CLIENT_INFO:
+-----------------------------------------+
| ClientId (8 Bytes) : FILETIME |
| NameLength (2 Bytes) : Byte length |
| Name (n Bytes) : UTF-16LE |
+-----------------------------------------+
Treacherous because: ClientId MUST match the authtime from the EncTicketPart exactly. Kerberos uses GeneralizedTime with second-level precision (20250207120000Z), but FILETIME has 100-nanosecond precision. If you construct the FILETIME from a DateTime object with sub-second precision, a mismatch occurs and the DC rejects the ticket with “invalid credential”.
# adPEAS fix: Truncate AuthTime to second-level precision
$AuthTime = [datetime]::new(
$AuthTime.Year, $AuthTime.Month, $AuthTime.Day,
$AuthTime.Hour, $AuthTime.Minute, $AuthTime.Second,
[System.DateTimeKind]::Utc
)
UPN_DNS_INFO (Type 12)
UPN_DNS_INFO:
+-------------------------------------------------+
| UpnLength (2 Bytes) |
| UpnOffset (2 Bytes) |
| DnsDomainLength (2 Bytes) |
| DnsDomainOffset (2 Bytes) |
| Flags (4 Bytes) |
| 0x00 = Standard |
| 0x01 = UPN_CONSTRUCTED (S-Flag) |
| 0x02 = EXTENDED (contains SamName + SID) |
| |
| (If Flags & 0x02): |
| SamNameLength (2 Bytes) |
| SamNameOffset (2 Bytes) |
| SidLength (2 Bytes) |
| SidOffset (2 Bytes) |
| |
| String data (at the offsets): |
| UPN: "admin@contoso.com" (UTF-16LE) |
| DnsDomain: "contoso.com" (UTF-16LE) |
| SamName: "admin" (UTF-16LE) (if EXTENDED) |
| SID: Raw SID bytes (if EXTENDED) |
+-------------------------------------------------+
adPEAS and Rubeus set Flags to 0x01 (UPN_CONSTRUCTED) — the simpler format without SamName/SID fields. Modern DCs internally use 0x02 (EXTENDED) with additional SamName and SID fields, but the 0x01 format is accepted by KDCs without issues.
PAC_ATTRIBUTES (Type 17)
PAC_ATTRIBUTES:
+-------------------------------------------+
| FlagsLength (4 Bytes) : 2 (= 2 bits) |
| Flags (4 Bytes) : 0x00000001 |
| Bit 0: PAC_WAS_REQUESTED = 1 |
+-------------------------------------------+
Just 8 bytes. Tells the KDC that the PAC was explicitly requested.
PAC_REQUESTOR (Type 18)
PAC_REQUESTOR:
+--------------------------------------------+
| Raw SID (no length prefix!): |
| Revision (1 Byte) : 1 |
| SubAuthCount (1 Byte) : 5 |
| Authority (6 Bytes) : NT Authority |
| SubAuth[0] (4 Bytes) : 21 |
| SubAuth[1] (4 Bytes) : Domain part 1 |
| SubAuth[2] (4 Bytes) : Domain part 2 |
| SubAuth[3] (4 Bytes) : Domain part 3 |
| SubAuth[4] (4 Bytes) : RID (e.g. 500) |
+--------------------------------------------+
This buffer was introduced with CVE-2021-42287. The DC verifies that the SID in PAC_REQUESTOR matches the user who requested the ticket. Without this buffer, the ticket is rejected on patched DCs.
Important for Diamond Tickets: When impersonating a different user, the RID in PAC_REQUESTOR must match the target user (not the base user).
Part 4: PAC Signatures — The Trust Anchor
How PAC Checksums Work
The PAC has two signatures that ensure it hasn’t been tampered with:
+---------------------------------------------------------+
| PAC Signature Procedure |
+---------------------------------------------------------+
| |
| Step 1: Compute SERVER_CHECKSUM |
| ----------------------------------------------- |
| 1a. Set server and KDC checksum fields to NULL |
| 1b. HMAC over the ENTIRE PAC (with zeroes) |
| 1c. Write result into SERVER_CHECKSUM |
| |
| Step 2: Compute KDC_CHECKSUM |
| ---------------------------------------- |
| 2a. HMAC over the SERVER_CHECKSUM bytes |
| (NOT over the entire PAC!) |
| 2b. Write result into KDC_CHECKSUM |
| |
| Key Usage for both: 17 |
| |
| For Golden Ticket: |
| Server Key = krbtgt key |
| KDC Key = krbtgt key |
| |
| For Silver Ticket: |
| Server Key = Service account key |
| KDC Key = Service account key <- Both the same! |
| |
| For legitimate TGT: |
| Server Key = krbtgt key |
| KDC Key = krbtgt key |
| |
+---------------------------------------------------------+
Checksum Format (PAC_SIGNATURE_DATA)
PAC_SIGNATURE_DATA:
+--------------------------------------------+
| SignatureType (4 Bytes, signed int32) |
| -138 = HMAC_MD5 (RC4) |
| 15 = HMAC_SHA1_96_AES128 |
| 16 = HMAC_SHA1_96_AES256 |
| |
| Signature (variable) |
| RC4: 16 Bytes (HMAC-MD5) |
| AES: 12 Bytes (truncated HMAC-SHA1) |
| |
| NO RODCIdentifier! |
| (only relevant for Read-Only DCs) |
+--------------------------------------------+
Key Derivation for PAC Checksums
This is where it gets subtle. RFC 3961 defines three derived keys:
Base Key
|
+--------------+-----------------+
v v v
DK(usage||0xAA) DK(usage||0x55) DK(usage||0x99)
Ke Ki Kc
(Encryption) (Integrity) (Checksum)
PAC checksums use Kc (suffix 0x99), NOT Ki (suffix 0x55).
Why does this matter? Ki is used for integrity checks within encrypted data (the HMAC at the end of an AES-CTS ciphertext). Kc is used for standalone checksums — and that’s exactly what a PAC signature is.
Overlooking this difference leads to a frustrating bug: The cryptography is technically correct (valid HMAC), but the KDC expects a different key and you get STATUS_WRONG_PASSWORD (0x56).
# adPEAS uses Windows Native Crypto for this reason:
$checksum = Get-KerberosChecksumNative `
-EncryptionType 18 ` # AES256 (internally mapped to checksum type 16)
-Key $krbtgtKey `
-Data $pacBytes `
-KeyUsage 17 # PAC Checksum usage
Silver Tickets: Both Checksums Are the Same
Normally, the server checksum is signed with the service account key and the KDC checksum is signed with the krbtgt key. But with Silver Tickets, we don’t know the krbtgt key — so we sign both with the service account key.
This works because the target service normally doesn’t forward the KDC checksum to the KDC for validation. It only verifies the server checksum using its own key.
Part 5: Golden Ticket — Step by Step
What Do We Need?
| Ingredient | Source | Example |
|---|---|---|
| krbtgt Key | DCSync, NTDS.dit | AES256: 52a4126c7ab14fe... |
| Domain SID | Get-DomainObject | S-1-5-21-1234-5678-9012 |
| Domain Name | known | CONTOSO.COM |
| Domain NetBIOS | Get-DomainObject | CONTOSO |
| User RID | optional (Default: 500) | 500 (Administrator) |
| Group RIDs | optional (Default: DA+DU+SA+EA+GPCO) | 512, 513, 518, 519, 520 |
The Build Process in adPEAS
+------------------------------------------------------------+
| New-GoldenTicket Flow |
+------------------------------------------------------------+
| |
| 1. Generate random session key |
| $sessionKey = [byte[]]::new(32) |
| [Security.Cryptography.RNGCryptoServiceProvider]:: |
| Create().GetBytes($sessionKey) |
| |
| 2. Set timestamps |
| $authTime = [DateTime]::UtcNow (truncated to seconds) |
| $endTime = $authTime.AddHours(10) |
| $renewTill = $authTime.AddDays($ValidityDays) |
| |
| 3. Build PAC (Build-PAC) |
| |-- Build-KerbValidationInfo (NDR) |
| | +-- User info, groups, domain SID, ExtraSIDs |
| |-- Build-PACClientInfo |
| | +-- authTime (FILETIME) + Username |
| |-- Build-PACUpnDnsInfo (Flags: 0x01 UPN_CONSTRUCTED) |
| | +-- UPN, DnsDomain, SamName, SID |
| |-- Build-PACAttributesInfo (0x01) |
| |-- Build-PACRequestor (User SID) |
| |-- Build-PACSignature (Server, zeroed) |
| +-- Build-PACSignature (KDC, zeroed) |
| |
| 4. Compute PAC checksums (Complete-PACSignatures) |
| |-- Set both checksum fields to NULL |
| |-- Server checksum = HMAC(krbtgt, entire PAC) |
| |-- Write server checksum |
| +-- KDC checksum = HMAC(krbtgt, server checksum) |
| |
| 5. Build EncTicketPart (New-EncTicketPart, ASN.1) |
| |-- flags: Forwardable, Renewable, Initial, PreAuth |
| |-- key: $sessionKey |
| |-- crealm, cname: User info |
| |-- authtime, endtime, renew-till |
| +-- authorization-data: AD-WIN2K-PAC(PAC) |
| |
| 6. Encrypt EncTicketPart |
| $cipher = Protect-KerberosNative( |
| EncryptionType=18, Key=$krbtgtKey, |
| Data=$encTicketPart, KeyUsage=2 |
| ) |
| |
| 7. Build Ticket (APPLICATION 1) |
| |-- tkt-vno: 5 |
| |-- realm: CONTOSO.COM |
| |-- sname: krbtgt/CONTOSO.COM |
| +-- enc-part: $cipher (etype 18, kvno 2) |
| |
| 8. Build KRB-CRED (Build-KRBCred) |
| |-- tickets: [Ticket] |
| +-- enc-part: EncKrbCredPart (unencrypted) |
| +-- key: $sessionKey |
| |
| 9. Optional: PTT via Import-KerberosTicket |
| +-- LSA API: LsaCallAuthenticationPackage |
| |
+------------------------------------------------------------+
# adPEAS invocation:
Invoke-TicketForge -Mode Golden `
-Domain "contoso.com" `
-DomainSID "S-1-5-21-1234-5678-9012" `
-AES256Key "52a4126c7ab14fe..." `
-PTT
What the KDC Sees (and What It Doesn’t)
Golden Ticket OPSEC Profile:
+-----------------------------------------------------+
| Event ID 4768 (TGT Request): NOT present |
| -> No AS-REQ was sent! |
| -> Suspicious: TGT exists without AS-REQ |
| |
| Event ID 4769 (TGS Request): Present |
| -> When you use the Golden Ticket for TGS-REQ |
| -> Looks "normal" |
| |
| Event ID 4770 (TGT Renew): Possible |
| -> If ValidityDays is very long -> suspicious |
| |
| Detection: |
| - No corresponding 4768 event for the TGT |
| - Unusually long ticket lifetime |
| - Groups the user doesn't normally have |
| - RC4 encryption in an AES environment |
+-----------------------------------------------------+
Part 6: Silver Ticket — Straight to the Service
Differences from the Golden Ticket
Golden Ticket:
Ticket.sname = krbtgt/CONTOSO.COM
Encrypted with = krbtgt key
Ticket Flags: Initial = YES
Usage: TGS-REQ -> Service Ticket -> Service
Silver Ticket:
Ticket.sname = cifs/fileserver.contoso.com (or another SPN)
Encrypted with = Service account key
Ticket Flags: Initial = NO
Usage: Directly to the service (AP-REQ) -- no KDC contact!
The key point: A Silver Ticket bypasses the KDC entirely. You send the forged service ticket directly to the target service. The service checks:
- Can I decrypt the enc-part with my key? -> Yes
- Is the EncTicketPart valid (timestamps, flags)? -> Yes
- What groups does the user have (according to the PAC)? -> Whatever you put in there
PAC Signatures for Silver Tickets
+-----------------------------------------------------+
| Normal Service Ticket (from the KDC): |
| Server Checksum: HMAC(Service-Key, PAC) |
| KDC Checksum: HMAC(krbtgt-Key, Server-CS) |
| |
| Silver Ticket (forged): |
| Server Checksum: HMAC(Service-Key, PAC) |
| KDC Checksum: HMAC(Service-Key, Server-CS) |
| ^^^^^^^^^^^^^^^^ |
| Same key! |
| |
| Works because: |
| The service only validates the Server Checksum |
| KDC Checksum is only checked when the service |
| requests PAC validation from the KDC (rare) |
+-----------------------------------------------------+
# Silver Ticket for LDAP:
Invoke-TicketForge -Mode Silver `
-Domain "contoso.com" `
-DomainSID "S-1-5-21-1234-5678-9012" `
-ServiceType LDAP -TargetComputer "dc01" `
-AES256Key "DC01_COMPUTER_AES256KEY" `
-PTT
Supported Service Types
| ServiceType | SPN becomes | Typical key owner |
|---|---|---|
| CIFS | cifs/host.domain.com | Computer Account (DC01$) |
| LDAP | ldap/host.domain.com | Computer Account (DC01$) |
| HTTP | http/host.domain.com | Computer Account or IIS Account |
| HOST | host/host.domain.com | Computer Account |
| MSSQL | MSSQLSvc/host.domain.com | SQL Service Account |
| WSMAN | wsman/host.domain.com | Computer Account |
Part 7: Diamond Ticket — The Hybrid Approach
Why Diamond Instead of Golden?
Golden Ticket Problem:
DC Logs: [4769: TGS-REQ from "admin"]
DC Logs: [no 4768 for "admin"!] <- Suspicious!
SOC: "Where did this TGT come from? There's no login!"
Diamond Ticket:
DC Logs: [4768: AS-REQ from "lowpriv"] <- Normal login
DC Logs: [4769: TGS-REQ from "lowpriv"] <- Normal service access
SOC: "Looks normal."
Reality: lowpriv's TGT has Domain Admins in the PAC!
The Hybrid Approach in Detail
The Diamond Ticket in adPEAS uses a “hybrid” approach: It combines a real TGT with the proven Golden Ticket logic for rebuilding the PAC.
Why not just patch the PAC in the original TGT?
Because NDR serialization is not deterministic. When you re-serialize the LOGON_INFO buffer (e.g., with new groups), Build-KerbValidationInfo produces an NDR blob that is byte-for-byte different from the original. Even if the logical data is the same. The KDC validates the PAC checksum — and it no longer matches.
We tested this approach. Result: KRB_AP_ERR_MODIFIED (Error 41) on every TGS-REQ.
The solution: Take the real TGT, extract the relevant metadata (timestamps, session key), and rebuild everything from scratch using the Golden Ticket logic:
+----------------------------------------------------------+
| Diamond Ticket Hybrid Flow |
+----------------------------------------------------------+
| |
| Phase 1: Obtain a real TGT |
| ------------------------- |
| Input: BaseUser credentials (Password/Hash/Key/.kirbi) |
| -> AS-REQ to KDC (generates Event 4768 in DC logs!) |
| -> AS-REP contains real TGT + session key |
| |
| Phase 2: Decrypt and parse the TGT |
| ---------------------------------- |
| 1. Parse KRB-CRED (ASN.1) |
| 2. Decrypt Ticket.enc-part with krbtgt key |
| 3. Parse EncTicketPart: |
| - Extract session key <- CRITICAL: preserve it! |
| - Extract authtime |
| - Parse PAC -> UserName, Domain, SID, RID, Groups |
| |
| Phase 3: Build new ticket with Golden logic |
| ------------------------------------------ |
| 1. Build-PAC with desired GroupRIDs |
| (e.g. 512=DA, 519=EA -- instead of real groups) |
| 2. Complete-PACSignatures with krbtgt key |
| 3. New-EncTicketPart with: |
| - ORIGINAL session key (don't generate a new one!) |
| - ORIGINAL authtime (must match CLIENT_INFO) |
| - New groups in the PAC |
| 4. Encrypt with krbtgt key (KeyUsage 2) |
| 5. Build Ticket + KRB-CRED with ORIGINAL session key |
| |
| Why the original session key? |
| ----------------------------- |
| The client knows the session key from the AS-REP. |
| The TGS-REQ Authenticator is encrypted with it. |
| If we generate a new session key, the Authenticator |
| won't match -> TGS-REQ fails. |
| |
| With Golden Tickets this isn't a problem because we |
| control both the session key in the ticket and in the |
| KRB-CRED. With Diamond Tickets, the client already |
| has the original key. |
| |
+----------------------------------------------------------+
OPSEC Modes
Mode 1: Stealthy (without -UserName)
----------------------------------
BaseUser = lowpriv
Ticket User = lowpriv <- Same as AS-REQ!
Groups = Domain Admins, Enterprise Admins
DC Logs: "lowpriv made AS-REQ" ✓
Ticket: "lowpriv is Domain Admin"
Detection: Only PAC content analysis (very rare)
Mode 2: Impersonation (with -UserName)
---------------------------------------
BaseUser = lowpriv
Ticket User = Administrator <- Different from AS-REQ!
Groups = Domain Admins, Enterprise Admins
DC Logs: "lowpriv made AS-REQ" ✓
Ticket: "Administrator is Domain Admin"
Detection: Username mismatch between 4768 and 4769
Encryption Type Constraint
Diamond Ticket Constraint:
Base TGT etype MUST match the krbtgt key!
Scenario:
DC encrypts TGT with AES256 (etype 18)
You only have the krbtgt NT hash (etype 23)
-> ERROR: Cannot decrypt TGT!
Solution A: Obtain the krbtgt AES256 key (DCSync with /all)
Solution B: Use a Golden Ticket instead of Diamond
Part 8: PAC Buffer Consistency
The Consistency Problem
All PAC buffers must describe the same user. When creating a Diamond Ticket with impersonation, all buffers must be updated:
Buffer Needs to be updated?
------------------------ ------------------------
LOGON_INFO (type 1) Yes -- UserName, RID, groups
CLIENT_INFO (type 10) Yes -- Client Name
UPN_DNS_INFO (type 12) Yes -- UPN, SamName, SID RID
PAC_ATTRIBUTES (type 17) No -- static (0x01)
PAC_REQUESTOR (type 18) Yes -- last SubAuth (RID)
SERVER_CHECKSUM (type 6) Yes -- recompute after all changes
KDC_CHECKSUM (type 7) Yes -- recompute after server checksum
If LOGON_INFO says “RID 500 (Administrator)” but PAC_REQUESTOR says “RID 1103 (lowpriv)”, the ticket will be rejected. Rubeus’ ModifyTicket therefore updates all relevant buffers consistently.
What Happens with Inconsistencies?
| Inconsistency | Result |
|---|---|
| LOGON_INFO RID ≠ PAC_REQUESTOR RID | Logon failure (ticket rejected) |
| CLIENT_INFO Name ≠ EncTicketPart cname | ”invalid credential” |
| CLIENT_INFO Timestamp ≠ EncTicketPart authtime | ”invalid credential” |
| PAC_REQUESTOR missing | Logon failure (on patched DCs) |
| TICKET_CHECKSUM present | Logon failure (KDC can’t validate it) |
| Server checksum wrong | KRB_AP_ERR_MODIFIED (Error 41) |
Part 9: Detection and Countermeasures
What Defenders Can See
Event ID 4768 (TGT Request):
- Golden Ticket: No event -> suspicious
- Diamond Ticket: Normal event -> stealthy
Event ID 4769 (TGS Request):
- Encryption Type 0x17 (RC4) in an AES environment -> suspicious
- Unexpected groups for the user -> only with PAC analysis
PAC-based Detection:
- Microsoft Defender for Identity can analyze PAC contents
- Comparison: “Does user X actually have Domain Admins membership?”
- LOGON_INFO groups vs. actual AD group membership
Ticket Lifetime Anomalies:
- Golden Ticket with 10-year validity -> obviously forged
- Diamond Ticket adopts domain policy -> inconspicuous
Countermeasures
| Measure | Against | Effectiveness |
|---|---|---|
| krbtgt Key Rotation | Golden + Diamond | High (invalidates all TGTs) |
| Credential Guard | Hash dumps for key material | High |
| PAC Validation (KDC) | Silver Tickets | Medium (performance impact) |
| AES-Only Policy | RC4-based tickets | Medium (reduces attack surface) |
| Protected Users | Various | Medium (no delegation, no RC4) |
| MDI/ATA | Anomaly detection | High (PAC analysis, TGT anomalies) |
| Frequent krbtgt Rotation | Persistence via Golden | High (recommended: every 180 days) |
krbtgt Key Rotation
The most effective defense against Golden and Diamond Tickets:
Rotation 1: New krbtgt key (kvno 3)
-> Old tickets (kvno 2) still work!
-> Kerberos allows N and N-1
Rotation 2 (after replication): New krbtgt key (kvno 4)
-> Now kvno 2 no longer works
-> Only kvno 3 and 4 are valid
-> All Golden/Diamond Tickets with the old key are invalidated
Recommendation: Two rotations with 12-24h interval
Summary
The Ticket Forging Hierarchy
+------------------------------------------------------+
| |
| Golden Ticket |
| |-- Requires: krbtgt key |
| |-- OPSEC: Poor (no AS-REQ in logs) |
| |-- Scope: Entire domain |
| +-- Complexity: Easiest |
| |
| Silver Ticket |
| |-- Requires: Service account key |
| |-- OPSEC: Medium (no TGS-REQ in logs) |
| |-- Scope: A single specific service |
| +-- Complexity: Medium (SPN must be known) |
| |
| Diamond Ticket |
| |-- Requires: krbtgt key + user credentials |
| |-- OPSEC: Good (real AS-REQ in logs) |
| |-- Scope: Entire domain |
| +-- Complexity: Highest (hybrid approach) |
| |
+------------------------------------------------------+
What You Should Understand Now
- The PAC is a Microsoft extension carrying user identity and group information inside the Kerberos ticket
- NDR serialization is the encoding for LOGON_INFO — with referents, alignment, and RPC_UNICODE_STRING
- 7 PAC buffers are required on modern DCs (CVE-2021-42287)
- PAC checksums use Kc (0x99), not Ki (0x55) — a subtle but critical distinction
- Golden Tickets build everything from scratch — maximum control, poor OPSEC
- Silver Tickets bypass the KDC entirely — limited scope, good OPSEC
- Diamond Tickets modify real TGTs — best OPSEC trade-off, highest complexity
- Session key preservation is critical for Diamond Tickets — otherwise the TGS-REQ fails
- Buffer consistency between LOGON_INFO, CLIENT_INFO, UPN_DNS_INFO, and PAC_REQUESTOR is mandatory
- krbtgt key rotation (2x) is the most effective countermeasure
← Episode 7: Kerberos Internals | Episode 9: Tips & Tricks — coming soon
About the Author
Related Articles
adPEAS v2 Episode 6: Offensive Operations - Manipulating AD with adPEAS
Hands-on guide to adPEAS v2 offensive capabilities: privilege escalation, persistence, lateral movement, GPO abuse, ADCS exploitation, and Kerberos ticket forging.
adPEAS v2 Episode 2: Under the Hood - Anatomy of a Scan
What happens when adPEAS scans an Active Directory? From authentication and LDAP queries to context-dependent severity ratings and caching -- a look under the hood.
adPEAS v2 Blog Series: Active Directory Security Analysis with adPEAS
Introducing adPEAS v2 — a complete rewrite of the PowerShell-based Active Directory analysis tool with native Kerberos support, zero dependencies, and over 40 security checks.