Files
fortra-impacket/tests/misc/test_ticketer.py
alexisbalbachan 899ef248d8 ticketer.py: preserve KDC-issued lifetime for diamond tickets (issue #2058) (#2181)
* preserve request-based ticket lifetime and add regression test

* tests: expand coverage for request-based ticket lifetime preservation

* Updated tests to comply with the code review

* Update examples/ticketer.py

* update description of the -duration parameter

---------

Co-authored-by: Gabriel Gonzalez <gabriel.gonzalez@fortra.com>
2026-05-01 00:56:43 -03:00

245 lines
11 KiB
Python

#!/usr/bin/env python
# Impacket - Collection of Python classes for working with network protocols.
#
# Copyright Fortra, LLC and its affiliated companies
#
# All rights reserved.
#
# This software is provided under a slightly modified version
# of the Apache Software License. See the accompanying LICENSE file
# for more information.
#
# Description:
# ticketer.py unit tests
#
import datetime
import unittest
from unittest import mock
from types import SimpleNamespace
from examples.ticketer import TICKETER
from pyasn1.codec.der import encoder
from pyasn1.type.univ import noValue
from impacket.krb5.asn1 import EncASRepPart, EncTGSRepPart, EncTicketPart
from impacket.krb5.constants import EncryptionTypes, PrincipalNameType, TicketFlags, encodeFlags
from impacket.krb5.types import KerberosTime
class TicketerTests(unittest.TestCase):
@staticmethod
def build_options(**overrides):
options = SimpleNamespace(
spn=None,
keytab=None,
request=False,
hashes=None,
aesKey='a' * 64,
nthash=None,
groups='513,512,520,518,519',
user_id='500',
extra_sid=None,
extra_pac=False,
old_pac=False,
duration='87600',
domain_sid='S-1-5-21-1-2-3',
impersonate=None,
user='administrator',
dc_ip='10.0.0.1',
)
for key, value in overrides.items():
setattr(options, key, value)
return options
@staticmethod
def build_encoded_reply_part(replyPartSpec, include_starttime=True, include_renew_till=True):
authtime = datetime.datetime(2026, 4, 28, 23, 25, 32, tzinfo=datetime.timezone.utc)
starttime = datetime.datetime(2026, 4, 28, 23, 25, 32, tzinfo=datetime.timezone.utc)
endtime = datetime.datetime(2026, 4, 29, 9, 25, 32, tzinfo=datetime.timezone.utc)
renewTill = datetime.datetime(2026, 4, 29, 23, 25, 4, tzinfo=datetime.timezone.utc)
part = replyPartSpec()
part['key'] = noValue
part['key']['keytype'] = EncryptionTypes.aes256_cts_hmac_sha1_96.value
part['key']['keyvalue'] = b'A' * 32
part['last-req'] = noValue
part['last-req'][0] = noValue
part['last-req'][0]['lr-type'] = 0
part['last-req'][0]['lr-value'] = KerberosTime.to_asn1(authtime)
part['nonce'] = 123456789
part['key-expiration'] = KerberosTime.to_asn1(endtime)
part['flags'] = encodeFlags([TicketFlags.forwardable.value, TicketFlags.renewable.value])
part['authtime'] = KerberosTime.to_asn1(authtime)
if include_starttime:
part['starttime'] = KerberosTime.to_asn1(starttime)
part['endtime'] = KerberosTime.to_asn1(endtime)
if include_renew_till:
part['renew-till'] = KerberosTime.to_asn1(renewTill)
part['srealm'] = 'A.LOCAL'
part['sname'] = noValue
part['sname']['name-type'] = PrincipalNameType.NT_SRV_INST.value
part['sname']['name-string'] = noValue
part['sname']['name-string'][0] = 'krbtgt'
part['sname']['name-string'][1] = 'A.LOCAL'
return encoder.encode(part)
def test_extract_reply_ticket_times_as_rep(self):
# Covers helper decryption/decoding for requested TGTs and verifies
# the AS-REP reply key usage. Reuse behavior is covered separately below.
class FakeCipher:
def __init__(self, plaintext):
self.plaintext = plaintext
self.calls = []
def decrypt(self, replyKey, keyUsage, cipherText):
self.calls.append((replyKey, keyUsage, cipherText))
return self.plaintext
options = self.build_options()
ticketer = TICKETER('baduser', 'Password123!', 'a.local', options)
fakeCipher = FakeCipher(self.build_encoded_reply_part(EncASRepPart))
with mock.patch.dict(
'examples.ticketer._enctype_table',
{EncryptionTypes.aes256_cts_hmac_sha1_96.value: fakeCipher},
clear=False,
):
extracted = ticketer._extract_reply_ticket_times(
{'enc-part': {'etype': EncryptionTypes.aes256_cts_hmac_sha1_96.value, 'cipher': b'ciphertext'}},
b'reply-key',
)
self.assertEqual(fakeCipher.calls, [(b'reply-key', 3, b'ciphertext')])
self.assertEqual(str(extracted['authtime']), '20260428232532Z')
self.assertEqual(str(extracted['starttime']), '20260428232532Z')
self.assertEqual(str(extracted['endtime']), '20260429092532Z')
self.assertEqual(str(extracted['renew-till']), '20260429232504Z')
def test_extract_reply_ticket_times_tgs_rep(self):
# Covers helper decryption/decoding for requested service tickets and
# verifies the TGS-REP reply key usage.
class FakeCipher:
def __init__(self, plaintext):
self.plaintext = plaintext
self.calls = []
def decrypt(self, replyKey, keyUsage, cipherText):
self.calls.append((replyKey, keyUsage, cipherText))
return self.plaintext
options = self.build_options(spn='cifs/fileserver.a.local')
ticketer = TICKETER('baduser', 'Password123!', 'a.local', options)
fakeCipher = FakeCipher(self.build_encoded_reply_part(EncTGSRepPart))
with mock.patch.dict(
'examples.ticketer._enctype_table',
{EncryptionTypes.aes256_cts_hmac_sha1_96.value: fakeCipher},
clear=False,
):
extracted = ticketer._extract_reply_ticket_times(
{'enc-part': {'etype': EncryptionTypes.aes256_cts_hmac_sha1_96.value, 'cipher': b'ciphertext'}},
b'reply-key',
)
self.assertEqual(fakeCipher.calls, [(b'reply-key', 8, b'ciphertext')])
self.assertEqual(str(extracted['authtime']), '20260428232532Z')
self.assertEqual(str(extracted['starttime']), '20260428232532Z')
self.assertEqual(str(extracted['endtime']), '20260429092532Z')
self.assertEqual(str(extracted['renew-till']), '20260429232504Z')
def test_extract_reply_ticket_times_missing_optional_fields_uses_fallbacks(self):
# Covers the helper fallback behavior when the KDC omits optional
# starttime or renew-till values.
class FakeCipher:
def __init__(self, plaintext):
self.plaintext = plaintext
def decrypt(self, replyKey, keyUsage, cipherText):
return self.plaintext
options = self.build_options()
ticketer = TICKETER('baduser', 'Password123!', 'a.local', options)
fakeCipher = FakeCipher(self.build_encoded_reply_part(
EncASRepPart,
include_starttime=False,
include_renew_till=False,
))
with mock.patch.dict(
'examples.ticketer._enctype_table',
{EncryptionTypes.aes256_cts_hmac_sha1_96.value: fakeCipher},
clear=False,
):
extracted = ticketer._extract_reply_ticket_times(
{'enc-part': {'etype': EncryptionTypes.aes256_cts_hmac_sha1_96.value, 'cipher': b'ciphertext'}},
b'reply-key',
)
self.assertEqual(str(extracted['authtime']), '20260428232532Z')
self.assertEqual(str(extracted['starttime']), '20260428232532Z')
self.assertEqual(str(extracted['endtime']), '20260429092532Z')
self.assertEqual(str(extracted['renew-till']), '20260429092532Z')
def test_createBasicTicket_request_stores_requested_ticket_times(self):
# Covers createBasicTicket() wiring only: the extraction helper is
# mocked here because its parsing behavior is verified by the helper
# tests above.
templateOptions = self.build_options()
templateTicketer = TICKETER('templateuser', 'Password123!', 'a.local', templateOptions)
templateReply, _ = templateTicketer.createBasicTicket()
options = self.build_options(request=True)
ticketer = TICKETER('baduser', 'Password123!', 'a.local', options)
expectedTimes = {'marker': 'times'}
with mock.patch('examples.ticketer.getKerberosTGT', return_value=(b'tgt-bytes', object(), b'reply-key', b'session-key')):
with mock.patch('examples.ticketer.decoder.decode', return_value=[templateReply]):
with mock.patch.object(TICKETER, '_extract_reply_ticket_times', return_value=expectedTimes) as extractMock:
ticketer.createBasicTicket()
extractMock.assert_called_once()
self.assertIs(ticketer._TICKETER__requested_ticket_times, expectedTimes)
def test_customizeTicket_request_reuses_requested_lifetime(self):
# Covers customizeTicket() consuming already-extracted lifetime values.
# This test intentionally seeds the cached values directly; extraction
# and request wiring are covered by the dedicated tests above.
options = self.build_options()
ticketer = TICKETER('baduser', 'Password123!', 'a.local', options)
kdcRep, pacInfos = ticketer.createBasicTicket()
authtime = datetime.datetime(2026, 4, 28, 23, 25, 32, tzinfo=datetime.timezone.utc)
starttime = datetime.datetime(2026, 4, 28, 23, 25, 32, tzinfo=datetime.timezone.utc)
endtime = datetime.datetime(2026, 4, 29, 9, 25, 32, tzinfo=datetime.timezone.utc)
renewTill = datetime.datetime(2026, 4, 29, 23, 25, 4, tzinfo=datetime.timezone.utc)
options.request = True
requested_times = EncTicketPart()
requested_times['authtime'] = KerberosTime.to_asn1(authtime)
requested_times['starttime'] = KerberosTime.to_asn1(starttime)
requested_times['endtime'] = KerberosTime.to_asn1(endtime)
requested_times['renew-till'] = KerberosTime.to_asn1(renewTill)
ticketer._TICKETER__requested_ticket_times = {
'authtime': requested_times['authtime'],
'starttime': requested_times['starttime'],
'endtime': requested_times['endtime'],
'renew-till': requested_times['renew-till'],
}
encRepPart, encTicketPart, _ = ticketer.customizeTicket(kdcRep, pacInfos)
self.assertEqual(str(encTicketPart['authtime']), '20260428232532Z')
self.assertEqual(str(encTicketPart['starttime']), '20260428232532Z')
self.assertEqual(str(encTicketPart['endtime']), '20260429092532Z')
self.assertEqual(str(encTicketPart['renew-till']), '20260429232504Z')
self.assertEqual(str(encRepPart['authtime']), '20260428232532Z')
self.assertEqual(str(encRepPart['starttime']), '20260428232532Z')
self.assertEqual(str(encRepPart['endtime']), '20260429092532Z')
self.assertEqual(str(encRepPart['renew-till']), '20260429232504Z')
if __name__ == "__main__":
unittest.main(verbosity=1)