py-mspack-decoder 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pac_decoder/__init__.py +220 -0
- pac_decoder/binary.py +170 -0
- pac_decoder/claims.py +130 -0
- pac_decoder/compression.py +229 -0
- pac_decoder/gssapi_helpers.py +97 -0
- pac_decoder/logon.py +247 -0
- pac_decoder/ndr.py +135 -0
- py_mspack_decoder-0.1.1.dist-info/METADATA +55 -0
- py_mspack_decoder-0.1.1.dist-info/RECORD +12 -0
- py_mspack_decoder-0.1.1.dist-info/WHEEL +5 -0
- py_mspack_decoder-0.1.1.dist-info/licenses/LICENSE +21 -0
- py_mspack_decoder-0.1.1.dist-info/top_level.txt +1 -0
pac_decoder/__init__.py
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""Pure-Python, dependency-free Microsoft Privilege Attribute Certificate decoding.
|
|
2
|
+
|
|
3
|
+
Security contract:
|
|
4
|
+
* ``decode_pac`` is a parser and does NOT authenticate PACs or signatures.
|
|
5
|
+
* For authorization, use ``decode_completed_context`` after GSSAPI has verified
|
|
6
|
+
the PAC name attribute, or otherwise establish the trust of the raw bytes.
|
|
7
|
+
* The default strict mode rejects ambiguous or malformed recognized buffers,
|
|
8
|
+
rather than returning an incomplete authorization identity.
|
|
9
|
+
* ``strict=False`` is for offline diagnostics ONLY, never authorization.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
from .binary import (
|
|
14
|
+
PACDecodeError, decode_attributes_info, decode_client_info,
|
|
15
|
+
decode_sid, decode_signature_type, decode_upn_dns_info, parse_pac,
|
|
16
|
+
)
|
|
17
|
+
from .claims import decode_claims_info
|
|
18
|
+
from .logon import decode_device_info, decode_logon_info, decode_delegation_info
|
|
19
|
+
|
|
20
|
+
BUFFER_NAMES = {
|
|
21
|
+
1: "logon_info", 2: "credential_info", 6: "server_checksum",
|
|
22
|
+
7: "kdc_checksum", 10: "client_info", 11: "constrained_delegation",
|
|
23
|
+
12: "upn_dns_info", 13: "client_claims_info", 14: "device_info",
|
|
24
|
+
15: "device_claims_info", 16: "ticket_checksum", 17: "attributes_info",
|
|
25
|
+
18: "requestor_sid", 19: "full_checksum",
|
|
26
|
+
}
|
|
27
|
+
SIG_NAMES = {6: "server", 7: "kdc", 16: "ticket", 19: "full"}
|
|
28
|
+
# [MS-PAC] 2.4 explicitly requires recipients to ignore additional instances
|
|
29
|
+
# of these known buffer types. PAC_REQUESTOR_SID (18) is deliberately omitted:
|
|
30
|
+
# the published type entry requires one but does not specify how to handle
|
|
31
|
+
# additional instances. Do not silently choose between different requestors.
|
|
32
|
+
# https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-pac/3341cfa2-6ef5-42e0-b7bc-4544884bf399
|
|
33
|
+
_IGNORABLE_DUPLICATE_TYPES = frozenset({
|
|
34
|
+
1, 2, 6, 7, 10, 11, 12, 13, 14, 15, 16, 17, 19,
|
|
35
|
+
})
|
|
36
|
+
_EMPTY = (
|
|
37
|
+
"sid", "sam_name", "upn", "dns_domain", "upn_dns_flags", "upn_constructed",
|
|
38
|
+
"rid", "domain_sid", "domain_name", "logon_server", "full_name",
|
|
39
|
+
"groups", "group_memberships", "primary_group_rid", "extra_sids", "resource_groups",
|
|
40
|
+
"group_count", "account_flags", "user_flags", "sub_auth_status",
|
|
41
|
+
"last_logon", "last_successful_logon", "last_failed_logon",
|
|
42
|
+
"logoff_time", "kickoff_time", "password_last_set",
|
|
43
|
+
"password_can_change", "password_must_change", "logon_count",
|
|
44
|
+
"bad_password_count", "failed_logon_count", "home_directory",
|
|
45
|
+
"home_drive", "logon_script", "profile_path", "client_name",
|
|
46
|
+
"client_auth_time", "requestor_sid", "pac_requested",
|
|
47
|
+
"pac_given_implicitly", "pac_attributes_flags", "delegation_proxy_target",
|
|
48
|
+
"delegation_transited_services", "device_info", "client_claims_present",
|
|
49
|
+
"device_claims_present", "client_claims", "device_claims",
|
|
50
|
+
"credential_info_present", "pac_signature_types", "pac_version",
|
|
51
|
+
"pac_size", "pac_buffer_types", "pac_buffer_type_ids",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def empty_pac_info() -> dict:
|
|
56
|
+
return dict.fromkeys(_EMPTY)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _same_name(left: str | None, right: str | None) -> bool:
|
|
60
|
+
"""SAM account names are case-insensitive; UPNs are separate identifiers."""
|
|
61
|
+
return left is None or right is None or left.casefold() == right.casefold()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _same_upn_info(left: dict, right: dict) -> bool:
|
|
65
|
+
"""Do not silently prefer a separately supplied GSS attribute over the PAC."""
|
|
66
|
+
for key in ("sid", "sam_name", "upn", "dns_domain"):
|
|
67
|
+
a, b = left.get(key), right.get(key)
|
|
68
|
+
if isinstance(a, str) and isinstance(b, str):
|
|
69
|
+
if a.casefold() != b.casefold():
|
|
70
|
+
return False
|
|
71
|
+
elif a != b:
|
|
72
|
+
return False
|
|
73
|
+
return left["upn_dns_flags"] == right["upn_dns_flags"]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def decode_pac(raw_pac: bytes, upn_dns_attribute: bytes | None = None,
|
|
77
|
+
*, errors: list[int] | None = None, strict: bool = True,
|
|
78
|
+
require_logon: bool = False,
|
|
79
|
+
ignore_invalid_claims: bool = False,
|
|
80
|
+
include_resource_groups: bool = True) -> dict:
|
|
81
|
+
"""Decode a raw MS-PAC as a dictionary; **does not authenticate it**.
|
|
82
|
+
In strict mode (default), malformed recognized buffers, conflicting
|
|
83
|
+
identity buffers, and missing/invalid LOGON_INFO (when
|
|
84
|
+
``require_logon=True``) raise PACDecodeError. For buffer types that MS-PAC
|
|
85
|
+
2.4 explicitly says may be duplicated, only the FIRST occurrence is
|
|
86
|
+
interpreted; later ones are ignored. Duplicate PAC_REQUESTOR_SID buffers
|
|
87
|
+
remain an error because MS-PAC does not specify an ignore rule for them.
|
|
88
|
+
Unknown buffer types are retained as metadata, not interpreted.
|
|
89
|
+
No partial result is returned.
|
|
90
|
+
``strict=False`` is ONLY for offline diagnostics. In this mode malformed
|
|
91
|
+
buffers are skipped and their type IDs are recorded in ``errors``. Its
|
|
92
|
+
output MUST NOT be used for authorization. This is intentionally explicit
|
|
93
|
+
to preserve the older diagnostic API while making normal use fail closed.
|
|
94
|
+
``upn_dns_attribute`` may ONLY come from an *independently authenticated*
|
|
95
|
+
and complete GSSAPI name attribute. The library can't establish trust
|
|
96
|
+
when caller-provided bytes are passed directly to this function.
|
|
97
|
+
|
|
98
|
+
``ignore_invalid_claims=True`` is an explicit compatibility mode: only
|
|
99
|
+
malformed optional client/device claims (types 13 and 15) are skipped,
|
|
100
|
+
never identity, logon, group, or other buffers. Missing claims must not
|
|
101
|
+
be treated as successful claims-based authorization.
|
|
102
|
+
``include_resource_groups=False`` excludes only PAC_LOGON_INFO
|
|
103
|
+
resource groups from top-level ``groups`` and ``resource_groups``.
|
|
104
|
+
It does not change raw ``group_memberships``, PAC_DEVICE_INFO, or
|
|
105
|
+
``extra_sids`` (which may contain resource-domain SIDs).
|
|
106
|
+
"""
|
|
107
|
+
if (type(strict) is not bool or type(require_logon) is not bool
|
|
108
|
+
or type(ignore_invalid_claims) is not bool
|
|
109
|
+
or type(include_resource_groups) is not bool):
|
|
110
|
+
raise TypeError("PAC decoder options must be booleans")
|
|
111
|
+
pac = parse_pac(raw_pac)
|
|
112
|
+
info = empty_pac_info()
|
|
113
|
+
info["pac_size"] = pac.size
|
|
114
|
+
info["pac_version"] = pac.version
|
|
115
|
+
info["pac_buffer_type_ids"] = [buf.type_id for buf in pac.buffers]
|
|
116
|
+
info["pac_buffer_types"] = [
|
|
117
|
+
BUFFER_NAMES.get(buf.type_id, f"unknown_{buf.type_id}")
|
|
118
|
+
for buf in pac.buffers
|
|
119
|
+
]
|
|
120
|
+
present = set(info["pac_buffer_type_ids"])
|
|
121
|
+
info["client_claims_present"] = 13 in present
|
|
122
|
+
info["device_claims_present"] = 15 in present
|
|
123
|
+
info["credential_info_present"] = 2 in present
|
|
124
|
+
info["pac_signature_types"] = {}
|
|
125
|
+
|
|
126
|
+
seen = set()
|
|
127
|
+
buffers = {}
|
|
128
|
+
for buf in pac.buffers:
|
|
129
|
+
if buf.type_id in seen:
|
|
130
|
+
# The first occurrence always wins; an invalid FIRST occurrence
|
|
131
|
+
# must still fail strict parsing (never fall back to a later one).
|
|
132
|
+
# parse_pac() already validated the directory spans of ALL buffers.
|
|
133
|
+
if (strict and buf.type_id in BUFFER_NAMES
|
|
134
|
+
and buf.type_id not in _IGNORABLE_DUPLICATE_TYPES):
|
|
135
|
+
raise PACDecodeError(
|
|
136
|
+
f"duplicate PAC buffer without defined ignore semantics: "
|
|
137
|
+
f"{BUFFER_NAMES[buf.type_id]} ({buf.type_id})"
|
|
138
|
+
)
|
|
139
|
+
continue
|
|
140
|
+
seen.add(buf.type_id)
|
|
141
|
+
buffers[buf.type_id] = buf.data
|
|
142
|
+
|
|
143
|
+
# Decode BOTH versions of UPN_DNS_INFO, if present. Do not allow a
|
|
144
|
+
# separately authenticated attribute to overwrite identity silently.
|
|
145
|
+
inline = buffers.get(12)
|
|
146
|
+
try:
|
|
147
|
+
if inline is not None:
|
|
148
|
+
info.update(decode_upn_dns_info(inline))
|
|
149
|
+
if upn_dns_attribute is not None:
|
|
150
|
+
separate = decode_upn_dns_info(upn_dns_attribute)
|
|
151
|
+
if inline is not None and not _same_upn_info(info, separate):
|
|
152
|
+
raise PACDecodeError("conflicting PAC and GSSAPI UPN_DNS_INFO")
|
|
153
|
+
if inline is None:
|
|
154
|
+
info.update(separate)
|
|
155
|
+
except PACDecodeError as exc:
|
|
156
|
+
if strict:
|
|
157
|
+
raise PACDecodeError(f"invalid PAC UPN_DNS_INFO: {exc}") from exc
|
|
158
|
+
if errors is not None:
|
|
159
|
+
errors.append(12)
|
|
160
|
+
# Do not expose even the original UPN fields on an inconsistent parse.
|
|
161
|
+
for key in ("sid", "sam_name", "upn", "dns_domain", "upn_dns_flags", "upn_constructed"):
|
|
162
|
+
info[key] = None
|
|
163
|
+
|
|
164
|
+
for t, data in buffers.items():
|
|
165
|
+
if t == 12:
|
|
166
|
+
continue
|
|
167
|
+
try:
|
|
168
|
+
if t == 1:
|
|
169
|
+
logon = decode_logon_info(data, include_resource_groups=include_resource_groups)
|
|
170
|
+
if not logon.get("sid") or not isinstance(logon.get("groups"), list):
|
|
171
|
+
raise PACDecodeError("LOGON_INFO missing account SID or group list")
|
|
172
|
+
if info["sid"] is not None and info["sid"] != logon["sid"]:
|
|
173
|
+
raise PACDecodeError("conflicting account SIDs in PAC identity buffers")
|
|
174
|
+
if not _same_name(info["sam_name"], logon["sam_name"]):
|
|
175
|
+
raise PACDecodeError("conflicting SAM account names in PAC identity buffers")
|
|
176
|
+
upn_sam = info["sam_name"]
|
|
177
|
+
info.update(logon)
|
|
178
|
+
if upn_sam is not None:
|
|
179
|
+
info["sam_name"] = upn_sam
|
|
180
|
+
elif t == 10:
|
|
181
|
+
info.update(decode_client_info(data))
|
|
182
|
+
elif t == 11:
|
|
183
|
+
info.update(decode_delegation_info(data))
|
|
184
|
+
elif t == 14:
|
|
185
|
+
info["device_info"] = decode_device_info(data)
|
|
186
|
+
elif t == 17:
|
|
187
|
+
info.update(decode_attributes_info(data))
|
|
188
|
+
elif t == 18:
|
|
189
|
+
info["requestor_sid"] = decode_sid(data)
|
|
190
|
+
elif t in SIG_NAMES:
|
|
191
|
+
info["pac_signature_types"][SIG_NAMES[t]] = decode_signature_type(data)
|
|
192
|
+
elif t in (13, 15):
|
|
193
|
+
key = "client_claims" if t == 13 else "device_claims"
|
|
194
|
+
# Samba and Windows may include a zero-length claims buffer
|
|
195
|
+
# when there are no claims. There is no NDR envelope to parse.
|
|
196
|
+
# Keep *_claims_present=True and decoded claims=None.
|
|
197
|
+
if data:
|
|
198
|
+
info[key] = decode_claims_info(data)
|
|
199
|
+
except PACDecodeError as exc:
|
|
200
|
+
if strict and not (ignore_invalid_claims and t in (13, 15)):
|
|
201
|
+
raise PACDecodeError(
|
|
202
|
+
f"invalid PAC {BUFFER_NAMES.get(t, f'buffer {t}')} ({t}): {exc}"
|
|
203
|
+
) from exc
|
|
204
|
+
if errors is not None:
|
|
205
|
+
errors.append(t)
|
|
206
|
+
# In diagnostic mode, never leak authorization identity from an
|
|
207
|
+
# invalid LOGON_INFO (even if UPN_DNS_INFO was decoded earlier).
|
|
208
|
+
if t == 1:
|
|
209
|
+
for field in ("groups", "group_memberships", "extra_sids", "resource_groups", "group_count"):
|
|
210
|
+
info[field] = None
|
|
211
|
+
if require_logon and (1 not in buffers or info["sid"] is None or info["groups"] is None):
|
|
212
|
+
raise PACDecodeError("valid PAC LOGON_INFO required")
|
|
213
|
+
return info
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
__all__ = [
|
|
217
|
+
"PACDecodeError", "parse_pac", "decode_sid", "decode_upn_dns_info",
|
|
218
|
+
"decode_logon_info", "decode_device_info", "decode_delegation_info",
|
|
219
|
+
"decode_pac", "empty_pac_info", "decode_claims_info",
|
|
220
|
+
]
|
pac_decoder/binary.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Dependency-free binary primitives for Microsoft MS-PAC.
|
|
2
|
+
|
|
3
|
+
No Kerberos validation is performed here. Only pass PAC data obtained from an
|
|
4
|
+
*authenticated and complete* GSSAPI name attribute.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from datetime import datetime, timedelta, timezone
|
|
11
|
+
import struct
|
|
12
|
+
|
|
13
|
+
MAX_PAC_BYTES = 4 * 1024 * 1024
|
|
14
|
+
MAX_PAC_ENTRIES = 16_384
|
|
15
|
+
MAX_PAC_GROUPS = 65_535 # Samba-style per-array group/SID bound.
|
|
16
|
+
MAX_GROUP_MEMBERSHIPS = 200_000 # Maximum number of distinct authorization group SIDs.
|
|
17
|
+
MAX_SID_SUBAUTHS = 15
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class PACDecodeError(ValueError):
|
|
21
|
+
"""Malformed, truncated or unsupported PAC encoding."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class PACBuffer:
|
|
26
|
+
type_id: int
|
|
27
|
+
data: bytes
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class PAC:
|
|
32
|
+
version: int
|
|
33
|
+
size: int
|
|
34
|
+
buffers: tuple[PACBuffer, ...]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def parse_pac(raw: bytes) -> PAC:
|
|
38
|
+
"""Validate and extract an MS-PAC PACTYPE (not an ASN.1 wrapper)."""
|
|
39
|
+
if not isinstance(raw, bytes) or not 8 <= len(raw) <= MAX_PAC_BYTES:
|
|
40
|
+
raise PACDecodeError("invalid PAC length or input type")
|
|
41
|
+
n, version = struct.unpack_from("<II", raw)
|
|
42
|
+
if n > MAX_PAC_ENTRIES or n > (len(raw) - 8) // 16:
|
|
43
|
+
raise PACDecodeError("invalid PAC directory size")
|
|
44
|
+
if version != 0:
|
|
45
|
+
raise PACDecodeError("unsupported PAC version")
|
|
46
|
+
directory_end = 8 + 16 * n
|
|
47
|
+
entries = []
|
|
48
|
+
ranges = []
|
|
49
|
+
for i in range(n):
|
|
50
|
+
type_id, size, offset = struct.unpack_from("<IIQ", raw, 8 + 16 * i)
|
|
51
|
+
if offset % 8 or offset < directory_end or offset > len(raw):
|
|
52
|
+
raise PACDecodeError("invalid PAC buffer offset")
|
|
53
|
+
if size > len(raw) - offset:
|
|
54
|
+
raise PACDecodeError("PAC buffer exceeds packet")
|
|
55
|
+
if size:
|
|
56
|
+
ranges.append((offset, offset + size))
|
|
57
|
+
entries.append((type_id, offset, size))
|
|
58
|
+
ranges.sort()
|
|
59
|
+
for previous, current in zip(ranges, ranges[1:]):
|
|
60
|
+
if previous[1] > current[0]:
|
|
61
|
+
raise PACDecodeError("overlapping PAC buffers")
|
|
62
|
+
buffers = tuple(PACBuffer(t, raw[o:o + n]) for t, o, n in entries)
|
|
63
|
+
return PAC(version=version, size=len(raw), buffers=buffers)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def decode_sid(raw: bytes) -> str:
|
|
67
|
+
"""Decode an exact self-relative binary SID, rejecting malformed SIDs."""
|
|
68
|
+
if len(raw) < 12:
|
|
69
|
+
raise PACDecodeError("SID too short")
|
|
70
|
+
revision, count = raw[0], raw[1]
|
|
71
|
+
if revision != 1 or not 1 <= count <= MAX_SID_SUBAUTHS:
|
|
72
|
+
raise PACDecodeError("unsupported SID revision or subauthority count")
|
|
73
|
+
if len(raw) != 8 + count * 4:
|
|
74
|
+
raise PACDecodeError("SID size mismatch")
|
|
75
|
+
authority = int.from_bytes(raw[2:8], "big")
|
|
76
|
+
subs = struct.unpack_from("<" + "I" * count, raw, 8)
|
|
77
|
+
return f"S-{revision}-{authority}" + "".join(f"-{n}" for n in subs)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def filetime_to_iso(value: int) -> str | None:
|
|
81
|
+
"""Convert Windows FILETIME, including sentinel values used by AD."""
|
|
82
|
+
if not 0 < value < 0x7FFFFFFFFFFFFFFF:
|
|
83
|
+
return None
|
|
84
|
+
try:
|
|
85
|
+
instant = datetime(1601, 1, 1, tzinfo=timezone.utc) + timedelta(
|
|
86
|
+
microseconds=value // 10
|
|
87
|
+
)
|
|
88
|
+
except (ValueError, OverflowError):
|
|
89
|
+
return None
|
|
90
|
+
return instant.isoformat(timespec="seconds").replace("+00:00", "Z")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def decode_upn_dns_info(data: bytes) -> dict:
|
|
94
|
+
"""UPN_DNS_INFO, including optional SAM and SID (PAC buffer type 12)."""
|
|
95
|
+
if not isinstance(data, bytes) or len(data) < 12:
|
|
96
|
+
raise PACDecodeError("truncated or invalid UPN_DNS_INFO")
|
|
97
|
+
upn_length, upn_offset, dns_length, dns_offset, flags = struct.unpack_from(
|
|
98
|
+
"<HHHHI", data
|
|
99
|
+
)
|
|
100
|
+
extended = bool(flags & 0x2)
|
|
101
|
+
header_size = 20 if extended else 12
|
|
102
|
+
if len(data) < header_size:
|
|
103
|
+
raise PACDecodeError("truncated extended UPN_DNS_INFO")
|
|
104
|
+
|
|
105
|
+
def read_text(length: int, offset: int) -> str | None:
|
|
106
|
+
if not length:
|
|
107
|
+
return None
|
|
108
|
+
if length & 1 or offset < header_size or length > len(data) - offset:
|
|
109
|
+
raise PACDecodeError("invalid UPN_DNS_INFO UTF-16 span")
|
|
110
|
+
try:
|
|
111
|
+
return data[offset:offset + length].decode("utf-16-le") or None
|
|
112
|
+
except UnicodeDecodeError as exc:
|
|
113
|
+
raise PACDecodeError("invalid UPN_DNS_INFO UTF-16") from exc
|
|
114
|
+
|
|
115
|
+
result = {
|
|
116
|
+
"upn": read_text(upn_length, upn_offset),
|
|
117
|
+
"dns_domain": read_text(dns_length, dns_offset),
|
|
118
|
+
"upn_dns_flags": flags,
|
|
119
|
+
"upn_constructed": bool(flags & 1),
|
|
120
|
+
"sam_name": None,
|
|
121
|
+
"sid": None,
|
|
122
|
+
}
|
|
123
|
+
if extended:
|
|
124
|
+
sam_length, sam_offset, sid_length, sid_offset = struct.unpack_from(
|
|
125
|
+
"<HHHH", data, 12
|
|
126
|
+
)
|
|
127
|
+
result["sam_name"] = read_text(sam_length, sam_offset)
|
|
128
|
+
if sid_length:
|
|
129
|
+
if sid_offset < header_size or sid_length > len(data) - sid_offset:
|
|
130
|
+
raise PACDecodeError("invalid UPN_DNS_INFO SID span")
|
|
131
|
+
result["sid"] = decode_sid(data[sid_offset:sid_offset + sid_length])
|
|
132
|
+
return result
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def decode_client_info(data: bytes) -> dict:
|
|
136
|
+
if len(data) < 10:
|
|
137
|
+
raise PACDecodeError("truncated PAC_CLIENT_INFO")
|
|
138
|
+
filetime, length = struct.unpack_from("<QH", data)
|
|
139
|
+
if length & 1 or length > len(data) - 10:
|
|
140
|
+
raise PACDecodeError("invalid client name length")
|
|
141
|
+
try:
|
|
142
|
+
name = data[10:10 + length].decode("utf-16-le").rstrip("\x00")
|
|
143
|
+
except UnicodeDecodeError as exc:
|
|
144
|
+
raise PACDecodeError("invalid client name UTF-16") from exc
|
|
145
|
+
return {"client_name": name or None, "client_auth_time": filetime_to_iso(filetime)}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def decode_attributes_info(data: bytes) -> dict:
|
|
149
|
+
if len(data) < 8:
|
|
150
|
+
raise PACDecodeError("truncated PAC_ATTRIBUTES_INFO")
|
|
151
|
+
nbits = struct.unpack_from("<I", data)[0]
|
|
152
|
+
if nbits > 256:
|
|
153
|
+
raise PACDecodeError("unsupported PAC flag length")
|
|
154
|
+
needed = ((nbits + 31) // 32) * 4
|
|
155
|
+
if needed > len(data) - 4:
|
|
156
|
+
raise PACDecodeError("truncated PAC_ATTRIBUTES_INFO flags")
|
|
157
|
+
flags = int.from_bytes(data[4:4 + needed], "little")
|
|
158
|
+
if nbits:
|
|
159
|
+
flags &= (1 << nbits) - 1
|
|
160
|
+
return {
|
|
161
|
+
"pac_attributes_flags": flags,
|
|
162
|
+
"pac_requested": bool(flags & 1),
|
|
163
|
+
"pac_given_implicitly": bool(flags & 2),
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def decode_signature_type(data: bytes) -> int:
|
|
168
|
+
if len(data) < 4:
|
|
169
|
+
raise PACDecodeError("truncated PAC_SIGNATURE_DATA")
|
|
170
|
+
return struct.unpack_from("<i", data)[0]
|
pac_decoder/claims.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""MS-ADTS CLAIMS_SET_METADATA and CLAIMS_SET, bounded NDR32 decoding.
|
|
2
|
+
|
|
3
|
+
No claims are returned unless the complete structure, including deferred
|
|
4
|
+
pointers and every value, was decoded successfully. No cryptography here.
|
|
5
|
+
"""
|
|
6
|
+
import struct
|
|
7
|
+
from .binary import PACDecodeError, MAX_PAC_ENTRIES, MAX_PAC_BYTES
|
|
8
|
+
from .ndr import ndr_serialized_pointer
|
|
9
|
+
from .compression import decompress_claims, MAX_CLAIMS_BYTES
|
|
10
|
+
|
|
11
|
+
_TYPE_NAMES = {1: 'int64', 2: 'uint64', 3: 'string', 6: 'boolean'}
|
|
12
|
+
_SOURCE_NAMES = {1: 'ad', 2: 'certificate'}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _array(r, pointer: int, count: int, *, bound=MAX_PAC_ENTRIES) -> bool:
|
|
16
|
+
if not 0 <= count <= bound:
|
|
17
|
+
raise PACDecodeError('excessive claims array count')
|
|
18
|
+
if not pointer:
|
|
19
|
+
if count:
|
|
20
|
+
raise PACDecodeError('nonempty null claims array')
|
|
21
|
+
return False
|
|
22
|
+
if r.u32() != count:
|
|
23
|
+
raise PACDecodeError('claims conformance count mismatch')
|
|
24
|
+
return True
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _blob(r, pointer: int, count: int, *, bound=MAX_CLAIMS_BYTES) -> bytes:
|
|
28
|
+
return r.bytes(count) if _array(r, pointer, count, bound=bound) else b''
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _string(r) -> str:
|
|
32
|
+
maximum, offset, actual = r.u32(), r.u32(), r.u32()
|
|
33
|
+
if (not actual or offset != 0 or actual > maximum
|
|
34
|
+
or maximum > MAX_CLAIMS_BYTES // 2):
|
|
35
|
+
raise PACDecodeError('invalid claims string bounds')
|
|
36
|
+
raw = r.bytes(actual * 2, 2)
|
|
37
|
+
if raw[-2:] != b'\0\0':
|
|
38
|
+
raise PACDecodeError('unterminated claims string')
|
|
39
|
+
try:
|
|
40
|
+
result = raw[:-2].decode('utf-16-le')
|
|
41
|
+
except UnicodeDecodeError as exc:
|
|
42
|
+
raise PACDecodeError('invalid UTF-16 claim') from exc
|
|
43
|
+
if '\0' in result:
|
|
44
|
+
raise PACDecodeError('embedded NUL in claim string')
|
|
45
|
+
return result
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _finish(r) -> None:
|
|
49
|
+
if len(r.data) - r.offset > 7:
|
|
50
|
+
raise PACDecodeError('unconsumed claims payload')
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def decode_claims_set(data: bytes) -> list[dict]:
|
|
54
|
+
"""Return source-tagged claim arrays from a serialized CLAIMS_SET."""
|
|
55
|
+
if not isinstance(data, bytes) or len(data) > MAX_CLAIMS_BYTES:
|
|
56
|
+
raise PACDecodeError('invalid claims input')
|
|
57
|
+
r = ndr_serialized_pointer(data)
|
|
58
|
+
count, arrays_ptr = r.u32(), r.u32()
|
|
59
|
+
r.u16()
|
|
60
|
+
reserved_size, reserved_ptr = r.u32(), r.u32()
|
|
61
|
+
arrays = []
|
|
62
|
+
total_entries = total_values = 0
|
|
63
|
+
if _array(r, arrays_ptr, count):
|
|
64
|
+
headers = [(r.u16(), r.u32(), r.u32()) for _ in range(count)]
|
|
65
|
+
for source, claim_count, entries_ptr in headers:
|
|
66
|
+
total_entries += claim_count
|
|
67
|
+
if total_entries > MAX_PAC_ENTRIES:
|
|
68
|
+
raise PACDecodeError('excessive total claims count')
|
|
69
|
+
entries = []
|
|
70
|
+
if _array(r, entries_ptr, claim_count):
|
|
71
|
+
fixed = []
|
|
72
|
+
for _ in range(claim_count):
|
|
73
|
+
id_ptr, typ, discriminant = r.u32(), r.u16(), r.u16()
|
|
74
|
+
if typ != discriminant or typ not in _TYPE_NAMES:
|
|
75
|
+
raise PACDecodeError('unsupported claims union')
|
|
76
|
+
n, value_ptr = r.u32(), r.u32()
|
|
77
|
+
total_values += n
|
|
78
|
+
if not id_ptr or not n or total_values > MAX_PAC_ENTRIES:
|
|
79
|
+
raise PACDecodeError('invalid claim identifier/value count')
|
|
80
|
+
fixed.append((typ, n, value_ptr))
|
|
81
|
+
for typ, n, value_ptr in fixed:
|
|
82
|
+
identifier = _string(r)
|
|
83
|
+
if not identifier:
|
|
84
|
+
raise PACDecodeError('empty claim identifier')
|
|
85
|
+
if not _array(r, value_ptr, n):
|
|
86
|
+
raise PACDecodeError('null claim value array')
|
|
87
|
+
if typ == 3:
|
|
88
|
+
refs = [r.u32() for _ in range(n)]
|
|
89
|
+
if not all(refs):
|
|
90
|
+
raise PACDecodeError('null claim string pointer')
|
|
91
|
+
values = [_string(r) for _ in refs]
|
|
92
|
+
else:
|
|
93
|
+
raw = r.bytes(n * 8, 8)
|
|
94
|
+
values = list(struct.unpack('<' + ('q' if typ == 1 else 'Q') * n, raw))
|
|
95
|
+
if typ == 6:
|
|
96
|
+
if any(value not in (0, 1) for value in values):
|
|
97
|
+
raise PACDecodeError('invalid boolean claim value')
|
|
98
|
+
values = [bool(value) for value in values]
|
|
99
|
+
entries.append({'id': identifier, 'type': _TYPE_NAMES[typ],
|
|
100
|
+
'type_id': typ, 'values': values})
|
|
101
|
+
arrays.append({'source': _SOURCE_NAMES.get(source, 'unknown'),
|
|
102
|
+
'source_id': source, 'claims': entries})
|
|
103
|
+
_blob(r, reserved_ptr, reserved_size)
|
|
104
|
+
_finish(r)
|
|
105
|
+
return arrays
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def decode_claims_info(data: bytes) -> dict:
|
|
109
|
+
"""Decode PAC buffers 13/15 (serialized CLAIMS_SET_METADATA).
|
|
110
|
+
|
|
111
|
+
Shape: {compression_format, uncompressed_size, arrays: [{source,
|
|
112
|
+
source_id, claims: [{id, type, type_id, values}]}]}. Unknown source IDs are
|
|
113
|
+
preserved numerically, never mislabeled as AD or certificate claims.
|
|
114
|
+
"""
|
|
115
|
+
if not isinstance(data, bytes) or len(data) > MAX_PAC_BYTES:
|
|
116
|
+
raise PACDecodeError('invalid claims metadata input')
|
|
117
|
+
r = ndr_serialized_pointer(data)
|
|
118
|
+
size, claims_ptr, compression = r.u32(), r.u32(), r.u16()
|
|
119
|
+
uncompressed_size = r.u32()
|
|
120
|
+
r.u16()
|
|
121
|
+
reserved_size, reserved_ptr = r.u32(), r.u32()
|
|
122
|
+
if not claims_ptr or size == 0:
|
|
123
|
+
raise PACDecodeError('missing encoded CLAIMS_SET')
|
|
124
|
+
compressed = _blob(r, claims_ptr, size, bound=MAX_PAC_BYTES)
|
|
125
|
+
_blob(r, reserved_ptr, reserved_size)
|
|
126
|
+
_finish(r)
|
|
127
|
+
plain = decompress_claims(compressed, compression, uncompressed_size)
|
|
128
|
+
arrays = decode_claims_set(plain)
|
|
129
|
+
return {'compression_format': compression,
|
|
130
|
+
'uncompressed_size': uncompressed_size, 'arrays': arrays}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""Bounded MS-XCA decompression using only the Python standard library.
|
|
2
|
+
|
|
3
|
+
Algorithms: NONE (0), LZNT1 (2), XPRESS/LZ77 (3), XPRESS_HUFF (4).
|
|
4
|
+
The caller must provide the expected output size from CLAIMS_SET_METADATA.
|
|
5
|
+
See SOURCES.md in the repository root for protocol references.
|
|
6
|
+
"""
|
|
7
|
+
import struct
|
|
8
|
+
from .binary import PACDecodeError, MAX_PAC_BYTES
|
|
9
|
+
|
|
10
|
+
MAX_CLAIMS_BYTES = 4 * 1024 * 1024
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class _Input:
|
|
14
|
+
def __init__(self, data: bytes):
|
|
15
|
+
self.data = data
|
|
16
|
+
self.pos = 0
|
|
17
|
+
|
|
18
|
+
def read(self, size: int) -> bytes:
|
|
19
|
+
if size < 0 or size > len(self.data) - self.pos:
|
|
20
|
+
raise PACDecodeError("truncated compressed claims")
|
|
21
|
+
result = self.data[self.pos:self.pos + size]
|
|
22
|
+
self.pos += size
|
|
23
|
+
return result
|
|
24
|
+
|
|
25
|
+
def u8(self) -> int:
|
|
26
|
+
return self.read(1)[0]
|
|
27
|
+
|
|
28
|
+
def u16(self) -> int:
|
|
29
|
+
return struct.unpack('<H', self.read(2))[0]
|
|
30
|
+
|
|
31
|
+
def u32(self) -> int:
|
|
32
|
+
return struct.unpack('<I', self.read(4))[0]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _match(out: bytearray, distance: int, length: int, limit: int,
|
|
36
|
+
window_start: int = 0) -> None:
|
|
37
|
+
if distance < 1 or distance > len(out) - window_start:
|
|
38
|
+
raise PACDecodeError("invalid compression back-reference")
|
|
39
|
+
if length < 3 or length > limit - len(out):
|
|
40
|
+
raise PACDecodeError("decompressed claims exceed expected size")
|
|
41
|
+
period = bytes(out[len(out) - distance:])
|
|
42
|
+
if length <= distance:
|
|
43
|
+
out.extend(period[:length])
|
|
44
|
+
else:
|
|
45
|
+
repetitions, remainder = divmod(length, distance)
|
|
46
|
+
out.extend(period * repetitions)
|
|
47
|
+
out.extend(period[:remainder])
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _lznt1(data: bytes, expected: int) -> bytes:
|
|
51
|
+
src, out = _Input(data), bytearray()
|
|
52
|
+
while src.pos < len(data):
|
|
53
|
+
header = src.u16()
|
|
54
|
+
if header == 0:
|
|
55
|
+
if any(data[src.pos:]):
|
|
56
|
+
raise PACDecodeError("nonzero data after LZNT1 terminator")
|
|
57
|
+
break
|
|
58
|
+
if header & 0x7000 != 0x3000:
|
|
59
|
+
raise PACDecodeError("invalid LZNT1 chunk signature")
|
|
60
|
+
chunk = _Input(src.read((header & 0x0FFF) + 1))
|
|
61
|
+
start = len(out)
|
|
62
|
+
limit = min(expected, start + 4096)
|
|
63
|
+
if not header & 0x8000:
|
|
64
|
+
if len(chunk.data) > limit - len(out):
|
|
65
|
+
raise PACDecodeError("oversized uncompressed LZNT1 chunk")
|
|
66
|
+
out.extend(chunk.data)
|
|
67
|
+
continue
|
|
68
|
+
while chunk.pos < len(chunk.data):
|
|
69
|
+
flags = chunk.u8()
|
|
70
|
+
for bit in range(8):
|
|
71
|
+
if chunk.pos == len(chunk.data):
|
|
72
|
+
break
|
|
73
|
+
if flags & (1 << bit):
|
|
74
|
+
token = chunk.u16()
|
|
75
|
+
shift, mask = 12, 0x0FFF
|
|
76
|
+
position = len(out) - start - 1
|
|
77
|
+
while position >= 16:
|
|
78
|
+
mask >>= 1
|
|
79
|
+
shift -= 1
|
|
80
|
+
position >>= 1
|
|
81
|
+
_match(out, (token >> shift) + 1, (token & mask) + 3,
|
|
82
|
+
limit, start)
|
|
83
|
+
else:
|
|
84
|
+
if len(out) >= limit:
|
|
85
|
+
raise PACDecodeError("oversized LZNT1 chunk")
|
|
86
|
+
out.append(chunk.u8())
|
|
87
|
+
return bytes(out)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _xpress(data: bytes, expected: int) -> bytes:
|
|
91
|
+
src, out = _Input(data), bytearray()
|
|
92
|
+
flags = bits = 0
|
|
93
|
+
pending_nibble = None
|
|
94
|
+
while len(out) < expected:
|
|
95
|
+
if bits == 0:
|
|
96
|
+
flags, bits = src.u32(), 32
|
|
97
|
+
bits -= 1
|
|
98
|
+
if not flags & (1 << bits):
|
|
99
|
+
out.append(src.u8())
|
|
100
|
+
continue
|
|
101
|
+
token = src.u16()
|
|
102
|
+
distance, length = (token >> 3) + 1, token & 7
|
|
103
|
+
if length == 7:
|
|
104
|
+
if pending_nibble is None:
|
|
105
|
+
packed = src.u8()
|
|
106
|
+
length, pending_nibble = packed & 15, packed >> 4
|
|
107
|
+
else:
|
|
108
|
+
length, pending_nibble = pending_nibble, None
|
|
109
|
+
if length == 15:
|
|
110
|
+
length = src.u8()
|
|
111
|
+
if length == 255:
|
|
112
|
+
length = src.u16()
|
|
113
|
+
if length == 0:
|
|
114
|
+
length = src.u32()
|
|
115
|
+
if length < 22:
|
|
116
|
+
raise PACDecodeError("invalid extended XPRESS length")
|
|
117
|
+
length -= 22
|
|
118
|
+
length += 15
|
|
119
|
+
length += 7
|
|
120
|
+
_match(out, distance, length + 3, expected)
|
|
121
|
+
if src.pos != len(data):
|
|
122
|
+
if not (bits == 0 and data[src.pos:] == b'\xff' * 4):
|
|
123
|
+
raise PACDecodeError("trailing XPRESS input")
|
|
124
|
+
return bytes(out)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class _Bits:
|
|
128
|
+
"""MS-XCA's two-word look-ahead, sharing input with long match lengths."""
|
|
129
|
+
|
|
130
|
+
def __init__(self, src: _Input):
|
|
131
|
+
self.src = src
|
|
132
|
+
self.register = (src.u16() << 16) | src.u16()
|
|
133
|
+
self.extra = 16
|
|
134
|
+
|
|
135
|
+
def take(self, count: int) -> int:
|
|
136
|
+
if not 0 <= count <= 15:
|
|
137
|
+
raise PACDecodeError("invalid Huffman bit count")
|
|
138
|
+
value = self.register >> (32 - count) if count else 0
|
|
139
|
+
self.register = (self.register << count) & 0xFFFFFFFF
|
|
140
|
+
self.extra -= count
|
|
141
|
+
if self.extra < 0:
|
|
142
|
+
self.register |= self.src.u16() << -self.extra
|
|
143
|
+
self.extra += 16
|
|
144
|
+
return value
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _huffman_table(raw: bytes):
|
|
148
|
+
lengths = [n for byte in raw for n in (byte & 15, byte >> 4)]
|
|
149
|
+
table = []
|
|
150
|
+
for length in range(1, 16):
|
|
151
|
+
for symbol, nbits in enumerate(lengths):
|
|
152
|
+
if nbits != length:
|
|
153
|
+
continue
|
|
154
|
+
count = 1 << (15 - length)
|
|
155
|
+
if count > 32768 - len(table):
|
|
156
|
+
raise PACDecodeError("oversubscribed Huffman tree")
|
|
157
|
+
table.extend([symbol] * count)
|
|
158
|
+
if len(table) != 32768:
|
|
159
|
+
raise PACDecodeError("incomplete Huffman tree")
|
|
160
|
+
return lengths, table
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _xpress_huff(data: bytes, expected: int) -> bytes:
|
|
164
|
+
src, out = _Input(data), bytearray()
|
|
165
|
+
while len(out) < expected:
|
|
166
|
+
lengths, table = _huffman_table(src.read(256))
|
|
167
|
+
bits = _Bits(src)
|
|
168
|
+
block_end = min(expected, len(out) + 65536)
|
|
169
|
+
while len(out) < block_end:
|
|
170
|
+
symbol = table[bits.register >> 17]
|
|
171
|
+
bits.take(lengths[symbol])
|
|
172
|
+
if symbol < 256:
|
|
173
|
+
out.append(symbol)
|
|
174
|
+
continue
|
|
175
|
+
code = symbol - 256
|
|
176
|
+
distance_bits, length = code >> 4, code & 15
|
|
177
|
+
if length == 15:
|
|
178
|
+
length = src.u8()
|
|
179
|
+
if length == 255:
|
|
180
|
+
length = src.u16()
|
|
181
|
+
if length == 0:
|
|
182
|
+
length = src.u32()
|
|
183
|
+
if length < 15:
|
|
184
|
+
raise PACDecodeError("invalid extended Huffman length")
|
|
185
|
+
length -= 15
|
|
186
|
+
length += 15
|
|
187
|
+
distance = (1 << distance_bits) + bits.take(distance_bits)
|
|
188
|
+
_match(out, distance, length + 3, expected)
|
|
189
|
+
if src.pos != len(data):
|
|
190
|
+
symbol = table[bits.register >> 17]
|
|
191
|
+
if symbol != 256:
|
|
192
|
+
raise PACDecodeError("missing Huffman end marker")
|
|
193
|
+
bits.take(lengths[symbol])
|
|
194
|
+
if src.pos != len(data):
|
|
195
|
+
raise PACDecodeError("trailing Huffman input")
|
|
196
|
+
return bytes(out)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def decompress_claims(data: bytes, format_id: int, expected_size: int) -> bytes:
|
|
200
|
+
"""Return exactly expected_size bytes or raise PACDecodeError.
|
|
201
|
+
|
|
202
|
+
This is a decoder, not an authentication function; never use its result
|
|
203
|
+
to trust an unsigned PAC.
|
|
204
|
+
"""
|
|
205
|
+
if (not isinstance(data, bytes) or len(data) > MAX_PAC_BYTES
|
|
206
|
+
or type(expected_size) is not int
|
|
207
|
+
or not 0 <= expected_size <= MAX_CLAIMS_BYTES):
|
|
208
|
+
raise PACDecodeError("invalid claims decompression size")
|
|
209
|
+
if expected_size == 0:
|
|
210
|
+
# Avoid interpreting a nonempty compressed stream when no output is
|
|
211
|
+
# expected (the XPRESS-Huffman decoder would have no symbol table).
|
|
212
|
+
if format_id not in (0, 2, 3, 4):
|
|
213
|
+
raise PACDecodeError("unsupported claims compression format")
|
|
214
|
+
if data:
|
|
215
|
+
raise PACDecodeError("nonempty compressed input for zero-size claims")
|
|
216
|
+
return b''
|
|
217
|
+
if format_id == 0:
|
|
218
|
+
result = data
|
|
219
|
+
elif format_id == 2:
|
|
220
|
+
result = _lznt1(data, expected_size)
|
|
221
|
+
elif format_id == 3:
|
|
222
|
+
result = _xpress(data, expected_size)
|
|
223
|
+
elif format_id == 4:
|
|
224
|
+
result = _xpress_huff(data, expected_size)
|
|
225
|
+
else:
|
|
226
|
+
raise PACDecodeError("unsupported claims compression format")
|
|
227
|
+
if len(result) != expected_size:
|
|
228
|
+
raise PACDecodeError("claims decompression size mismatch")
|
|
229
|
+
return result
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Read an authenticated MS-PAC from a completed Kerberos GSSAPI acceptor.
|
|
2
|
+
|
|
3
|
+
This module does not import python-gssapi at import time. GSSAPI performs the
|
|
4
|
+
PAC verification; this parser must never be given unchecked attributes.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from . import PACDecodeError, decode_pac
|
|
9
|
+
from .binary import MAX_PAC_BYTES
|
|
10
|
+
|
|
11
|
+
_KERBEROS_MECH = "1.2.840.113554.1.2.2"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class PACAttributeError(PACDecodeError):
|
|
15
|
+
"""An acceptor context has no usable, authenticated PAC name attribute."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _attribute_value(name: object, key: str, *, required: bool) -> bytes | None:
|
|
19
|
+
"""Extract a *single* authenticated, complete RFC 6680 name attribute."""
|
|
20
|
+
try:
|
|
21
|
+
attributes = name.attributes
|
|
22
|
+
if attributes is None:
|
|
23
|
+
raise PACAttributeError("GSSAPI name attributes are unavailable")
|
|
24
|
+
# The public Name.attributes mapping may raise a native GSSAPI error
|
|
25
|
+
# rather than KeyError when fetching a missing attribute. Enumerate
|
|
26
|
+
# first so the absence of an optional attribute is unambiguous.
|
|
27
|
+
keys = list(attributes.keys())
|
|
28
|
+
if key in keys:
|
|
29
|
+
lookup_key = key
|
|
30
|
+
elif key.encode("ascii") in keys:
|
|
31
|
+
lookup_key = key.encode("ascii")
|
|
32
|
+
else:
|
|
33
|
+
if required:
|
|
34
|
+
raise PACAttributeError(f"missing GSSAPI name attribute: {key}")
|
|
35
|
+
return None
|
|
36
|
+
attribute = attributes[lookup_key]
|
|
37
|
+
except PACAttributeError:
|
|
38
|
+
raise
|
|
39
|
+
except Exception as exc:
|
|
40
|
+
raise PACAttributeError(f"unable to obtain GSSAPI name attribute: {key}") from exc
|
|
41
|
+
if not getattr(attribute, "authenticated", False):
|
|
42
|
+
raise PACAttributeError(f"unauthenticated GSSAPI name attribute: {key}")
|
|
43
|
+
if not getattr(attribute, "complete", False):
|
|
44
|
+
raise PACAttributeError(f"incomplete GSSAPI name attribute: {key}")
|
|
45
|
+
try:
|
|
46
|
+
values = list(attribute.values)
|
|
47
|
+
except (AttributeError, TypeError) as exc:
|
|
48
|
+
raise PACAttributeError(f"invalid GSSAPI name attribute values: {key}") from exc
|
|
49
|
+
if len(values) != 1 or type(values[0]) is not bytes:
|
|
50
|
+
raise PACAttributeError(f"expected exactly one binary GSSAPI attribute: {key}")
|
|
51
|
+
if not 0 < len(values[0]) <= MAX_PAC_BYTES:
|
|
52
|
+
raise PACAttributeError(f"invalid GSSAPI name attribute size: {key}")
|
|
53
|
+
return values[0]
|
|
54
|
+
|
|
55
|
+
def decode_completed_context(context: object, *, errors: list[int] | None = None,
|
|
56
|
+
require_logon: bool = True,
|
|
57
|
+
ignore_invalid_claims: bool = False,
|
|
58
|
+
include_resource_groups: bool = True) -> dict:
|
|
59
|
+
"""Decode the PAC of a completed **Kerberos acceptor** context.
|
|
60
|
+
Rejects incomplete contexts, non-Kerberos mechanisms, unauthenticated or
|
|
61
|
+
incomplete PAC attributes and malformed or ambiguous PAC structures.
|
|
62
|
+
``require_logon=True`` ensures the identity and group set are complete.
|
|
63
|
+
No PAC cryptography is implemented in this package; the verification is
|
|
64
|
+
delegated to the underlying GSSAPI / Kerberos implementation.
|
|
65
|
+
|
|
66
|
+
``ignore_invalid_claims`` can preserve valid identity and groups when an
|
|
67
|
+
optional client/device claims buffer is malformed. Never authorize based
|
|
68
|
+
on claims whose decoded value is None.
|
|
69
|
+
``include_resource_groups=False`` excludes only PAC_LOGON_INFO
|
|
70
|
+
resource groups from top-level authorization lists. Device groups
|
|
71
|
+
and ExtraSids are unaffected.
|
|
72
|
+
"""
|
|
73
|
+
if not getattr(context, "complete", False):
|
|
74
|
+
raise PACAttributeError("GSSAPI security context is not complete")
|
|
75
|
+
if getattr(context, "locally_initiated", None) is not False:
|
|
76
|
+
raise PACAttributeError("expected a GSSAPI acceptor, not initiator")
|
|
77
|
+
try:
|
|
78
|
+
mechanism = context.mech
|
|
79
|
+
except Exception as exc:
|
|
80
|
+
raise PACAttributeError("unable to determine negotiated GSSAPI mechanism") from exc
|
|
81
|
+
if getattr(mechanism, "dotted_form", None) != _KERBEROS_MECH:
|
|
82
|
+
raise PACAttributeError("GSSAPI mechanism is not Kerberos")
|
|
83
|
+
try:
|
|
84
|
+
name = context.initiator_name
|
|
85
|
+
except Exception as exc:
|
|
86
|
+
raise PACAttributeError("GSSAPI initiator name is unavailable") from exc
|
|
87
|
+
if name is None:
|
|
88
|
+
raise PACAttributeError("GSSAPI initiator name is unavailable")
|
|
89
|
+
pac = _attribute_value(name, "urn:mspac:", required=True)
|
|
90
|
+
upn = _attribute_value(name, "urn:mspac:upn-dns-info", required=False)
|
|
91
|
+
return decode_pac(pac, upn_dns_attribute=upn,
|
|
92
|
+
errors=errors, strict=True, require_logon=require_logon,
|
|
93
|
+
ignore_invalid_claims=ignore_invalid_claims,
|
|
94
|
+
include_resource_groups=include_resource_groups)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
__all__ = ["PACAttributeError", "decode_completed_context"]
|
pac_decoder/logon.py
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""Specialized NDR32 decoders for PAC_LOGON_INFO and PAC_DEVICE_INFO."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .binary import PACDecodeError, filetime_to_iso, MAX_PAC_ENTRIES, MAX_GROUP_MEMBERSHIPS, MAX_SID_SUBAUTHS
|
|
6
|
+
from .ndr import ndr_serialized_pointer
|
|
7
|
+
|
|
8
|
+
_ENABLED = 0x00000004
|
|
9
|
+
_DENY_ONLY = 0x00000010
|
|
10
|
+
_NETLOGON_EXTRA_SIDS = 0x00000020
|
|
11
|
+
_NETLOGON_RESOURCE_GROUPS = 0x00000200
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _include(flags: int) -> bool:
|
|
15
|
+
return bool(flags & _ENABLED) and not bool(flags & _DENY_ONLY)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _valid_rid(rid: int) -> bool:
|
|
19
|
+
return 0 < rid <= 0xFFFFFFFF
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _append_rid(domain_sid: str, rid: int) -> str:
|
|
23
|
+
if domain_sid.count("-") >= MAX_SID_SUBAUTHS + 2:
|
|
24
|
+
raise PACDecodeError("SID exceeds maximum subauthorities after appending RID")
|
|
25
|
+
return f"{domain_sid}-{rid}"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _rid_sids(domain_sid: str | None, memberships: list[tuple[int, int]]) -> list[str]:
|
|
29
|
+
if not domain_sid:
|
|
30
|
+
if memberships:
|
|
31
|
+
raise PACDecodeError("group RIDs without domain SID")
|
|
32
|
+
return []
|
|
33
|
+
if any(not _valid_rid(rid) for rid, _ in memberships):
|
|
34
|
+
raise PACDecodeError("invalid group RID")
|
|
35
|
+
if memberships and domain_sid.count("-") >= MAX_SID_SUBAUTHS + 2:
|
|
36
|
+
raise PACDecodeError("SID exceeds maximum subauthorities after appending RID")
|
|
37
|
+
return [f"{domain_sid}-{rid}" for rid, flags in memberships
|
|
38
|
+
if _include(flags)]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _extra_sids(entries: list[tuple[str | None, int]]) -> list[str]:
|
|
42
|
+
return [sid for sid, flags in entries if sid and _include(flags)]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _dedupe(*sublists: list[str]) -> list[str]:
|
|
46
|
+
unique = []
|
|
47
|
+
seen = set()
|
|
48
|
+
for sublist in sublists:
|
|
49
|
+
for value in sublist:
|
|
50
|
+
if value not in seen:
|
|
51
|
+
seen.add(value)
|
|
52
|
+
unique.append(value)
|
|
53
|
+
if len(unique) > MAX_GROUP_MEMBERSHIPS:
|
|
54
|
+
raise PACDecodeError("too many combined authorization groups")
|
|
55
|
+
return unique
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def decode_logon_info(data: bytes, *, include_resource_groups: bool = True) -> dict:
|
|
59
|
+
"""Decode KERB_VALIDATION_INFO, returning *no* session keys or secrets.
|
|
60
|
+
|
|
61
|
+
Results must only be used for authorization if the enclosing GSSAPI PAC
|
|
62
|
+
attribute was authenticated and this function returned successfully.
|
|
63
|
+
``group_memberships`` preserves raw per-entry attributes; ``groups`` is
|
|
64
|
+
the filtered authorization list. The primary group has no PAC attributes.
|
|
65
|
+
``include_resource_groups=False`` filters only ResourceGroupIds from
|
|
66
|
+
``groups`` and ``resource_groups``, not ``extra_sids`` or raw metadata.
|
|
67
|
+
"""
|
|
68
|
+
if type(include_resource_groups) is not bool:
|
|
69
|
+
raise TypeError("include_resource_groups must be a boolean")
|
|
70
|
+
r = ndr_serialized_pointer(data)
|
|
71
|
+
dates = [r.filetime() for _ in range(6)]
|
|
72
|
+
first_names = [r.unicode_header() for _ in range(6)]
|
|
73
|
+
logon_count, bad_password_count = r.u16(), r.u16()
|
|
74
|
+
rid, primary_gid, group_count, group_ref, user_flags = (
|
|
75
|
+
r.u32(), r.u32(), r.u32(), r.u32(), r.u32()
|
|
76
|
+
)
|
|
77
|
+
r.bytes(16) # UserSessionKey: intentionally ignored.
|
|
78
|
+
server_name_hdr, domain_name_hdr = r.unicode_header(), r.unicode_header()
|
|
79
|
+
domain_sid_ref = r.u32()
|
|
80
|
+
r.bytes(8) # LMKey / Reserved1: never expose.
|
|
81
|
+
account_flags = r.u32()
|
|
82
|
+
reserved3 = [r.u32() for _ in range(7)]
|
|
83
|
+
sid_count, extra_ref, resource_domain_ref, resource_count, resource_ref = (
|
|
84
|
+
r.u32(), r.u32(), r.u32(), r.u32(), r.u32()
|
|
85
|
+
)
|
|
86
|
+
if (sid_count or extra_ref) and not (user_flags & _NETLOGON_EXTRA_SIDS):
|
|
87
|
+
raise PACDecodeError("ExtraSids without NETLOGON_EXTRA_SIDS flag")
|
|
88
|
+
if ((resource_domain_ref or resource_count or resource_ref)
|
|
89
|
+
and not (user_flags & _NETLOGON_RESOURCE_GROUPS)):
|
|
90
|
+
raise PACDecodeError("resource groups without NETLOGON_RESOURCE_GROUPS flag")
|
|
91
|
+
names = [r.unicode_value(h) for h in first_names]
|
|
92
|
+
memberships = r.group_array(group_ref, group_count)
|
|
93
|
+
server_name = r.unicode_value(server_name_hdr)
|
|
94
|
+
domain_name = r.unicode_value(domain_name_hdr)
|
|
95
|
+
domain_sid = r.sid_pointer(domain_sid_ref)
|
|
96
|
+
extra_entries = r.sid_and_attributes_array(extra_ref, sid_count)
|
|
97
|
+
resource_domain_sid = r.sid_pointer(resource_domain_ref)
|
|
98
|
+
resource_entries = r.group_array(resource_ref, resource_count)
|
|
99
|
+
if memberships and not domain_sid:
|
|
100
|
+
raise PACDecodeError("group RIDs without account domain SID")
|
|
101
|
+
if resource_entries and not resource_domain_sid:
|
|
102
|
+
raise PACDecodeError("resource RIDs without resource domain SID")
|
|
103
|
+
if len(r.data) - r.offset > 7:
|
|
104
|
+
raise PACDecodeError("unconsumed LOGON_INFO payload")
|
|
105
|
+
if not domain_sid:
|
|
106
|
+
raise PACDecodeError("LOGON_INFO missing account domain SID")
|
|
107
|
+
if not _valid_rid(primary_gid):
|
|
108
|
+
raise PACDecodeError("LOGON_INFO invalid primary group RID")
|
|
109
|
+
if rid == 0 and not extra_entries:
|
|
110
|
+
raise PACDecodeError("LOGON_INFO zero user RID without account ExtraSID")
|
|
111
|
+
normal_groups = _rid_sids(domain_sid, memberships)
|
|
112
|
+
account_extra = extra_entries[0][0] if rid == 0 and extra_entries else None
|
|
113
|
+
group_extras = extra_entries[1:] if rid == 0 else extra_entries
|
|
114
|
+
extra_sids = _dedupe(_extra_sids(group_extras))
|
|
115
|
+
resource_groups = _dedupe(_rid_sids(resource_domain_sid, resource_entries))
|
|
116
|
+
primary_sid = _append_rid(domain_sid, primary_gid)
|
|
117
|
+
user_sid = _append_rid(domain_sid, rid) if _valid_rid(rid) else None
|
|
118
|
+
if rid == 0 and extra_entries:
|
|
119
|
+
user_sid = account_extra
|
|
120
|
+
if not user_sid:
|
|
121
|
+
raise PACDecodeError("LOGON_INFO missing account SID")
|
|
122
|
+
groups = _dedupe(
|
|
123
|
+
[primary_sid], normal_groups, extra_sids,
|
|
124
|
+
resource_groups if include_resource_groups else []
|
|
125
|
+
)
|
|
126
|
+
denied = {f"{domain_sid}-{item}" for item, flags in memberships
|
|
127
|
+
if domain_sid and flags & _DENY_ONLY}
|
|
128
|
+
denied.update(sid for sid, flags in group_extras if sid and flags & _DENY_ONLY)
|
|
129
|
+
if include_resource_groups:
|
|
130
|
+
denied.update(f"{resource_domain_sid}-{item}" for item, flags in resource_entries
|
|
131
|
+
if resource_domain_sid and flags & _DENY_ONLY)
|
|
132
|
+
groups = [sid for sid in groups if sid not in denied]
|
|
133
|
+
extra_sids = [sid for sid in extra_sids if sid not in denied]
|
|
134
|
+
resource_groups = ([sid for sid in resource_groups if sid not in denied]
|
|
135
|
+
if include_resource_groups else [])
|
|
136
|
+
return {
|
|
137
|
+
"sid": user_sid,
|
|
138
|
+
"sam_name": names[0],
|
|
139
|
+
"rid": rid,
|
|
140
|
+
"domain_sid": domain_sid,
|
|
141
|
+
"domain_name": domain_name,
|
|
142
|
+
"logon_server": server_name,
|
|
143
|
+
"full_name": names[1],
|
|
144
|
+
"logon_script": names[2],
|
|
145
|
+
"profile_path": names[3],
|
|
146
|
+
"home_directory": names[4],
|
|
147
|
+
"home_drive": names[5],
|
|
148
|
+
"groups": groups,
|
|
149
|
+
"group_memberships": (
|
|
150
|
+
[{"sid": f"{domain_sid}-{group_rid}", "attributes": attrs,
|
|
151
|
+
"source": "account_domain"} for group_rid, attrs in memberships]
|
|
152
|
+
+ [{"sid": sid, "attributes": attrs, "source": "extra"}
|
|
153
|
+
for sid, attrs in group_extras]
|
|
154
|
+
+ [{"sid": f"{resource_domain_sid}-{group_rid}", "attributes": attrs,
|
|
155
|
+
"source": "resource"} for group_rid, attrs in resource_entries]
|
|
156
|
+
),
|
|
157
|
+
"primary_group_rid": primary_gid,
|
|
158
|
+
"extra_sids": extra_sids,
|
|
159
|
+
"resource_groups": resource_groups,
|
|
160
|
+
"group_count": len(groups),
|
|
161
|
+
"account_flags": account_flags,
|
|
162
|
+
"user_flags": user_flags,
|
|
163
|
+
"sub_auth_status": reserved3[0],
|
|
164
|
+
"last_logon": filetime_to_iso(dates[0]),
|
|
165
|
+
"logoff_time": filetime_to_iso(dates[1]),
|
|
166
|
+
"kickoff_time": filetime_to_iso(dates[2]),
|
|
167
|
+
"password_last_set": filetime_to_iso(dates[3]),
|
|
168
|
+
"password_can_change": filetime_to_iso(dates[4]),
|
|
169
|
+
"password_must_change": filetime_to_iso(dates[5]),
|
|
170
|
+
"last_successful_logon": filetime_to_iso(reserved3[1] | (reserved3[2] << 32)),
|
|
171
|
+
"last_failed_logon": filetime_to_iso(reserved3[3] | (reserved3[4] << 32)),
|
|
172
|
+
"failed_logon_count": reserved3[5],
|
|
173
|
+
"logon_count": logon_count,
|
|
174
|
+
"bad_password_count": bad_password_count,
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def decode_device_info(data: bytes) -> dict:
|
|
179
|
+
"""Decode the PAC_DEVICE_INFO account, extra SID and domain-group lists."""
|
|
180
|
+
r = ndr_serialized_pointer(data)
|
|
181
|
+
rid, primary_gid, domain_sid_ref, group_count, group_ref = (
|
|
182
|
+
r.u32(), r.u32(), r.u32(), r.u32(), r.u32()
|
|
183
|
+
)
|
|
184
|
+
sid_count, sid_ref, domain_count, domains_ref = (
|
|
185
|
+
r.u32(), r.u32(), r.u32(), r.u32()
|
|
186
|
+
)
|
|
187
|
+
domain_sid = r.sid_pointer(domain_sid_ref)
|
|
188
|
+
normal = r.group_array(group_ref, group_count)
|
|
189
|
+
extras = r.sid_and_attributes_array(sid_ref, sid_count)
|
|
190
|
+
domains = r.domain_groups_array(domains_ref, domain_count)
|
|
191
|
+
if normal and not domain_sid:
|
|
192
|
+
raise PACDecodeError("device group RIDs without domain SID")
|
|
193
|
+
if any(groups and not sid for sid, groups in domains):
|
|
194
|
+
raise PACDecodeError("device resource RIDs without domain SID")
|
|
195
|
+
if len(r.data) - r.offset > 7:
|
|
196
|
+
raise PACDecodeError("unconsumed DEVICE_INFO payload")
|
|
197
|
+
if not domain_sid or not _valid_rid(rid) or not _valid_rid(primary_gid):
|
|
198
|
+
raise PACDecodeError("DEVICE_INFO missing or invalid account SID/group")
|
|
199
|
+
group_sids = _rid_sids(domain_sid, normal)
|
|
200
|
+
extra_sids = _dedupe(_extra_sids(extras))
|
|
201
|
+
resource_sids = _dedupe(*[_rid_sids(sid, groups) for sid, groups in domains])
|
|
202
|
+
primary_sid = _append_rid(domain_sid, primary_gid)
|
|
203
|
+
device_sid = _append_rid(domain_sid, rid)
|
|
204
|
+
return {
|
|
205
|
+
"sid": device_sid,
|
|
206
|
+
"rid": rid,
|
|
207
|
+
"domain_sid": domain_sid,
|
|
208
|
+
"primary_group_rid": primary_gid,
|
|
209
|
+
"extra_sids": extra_sids,
|
|
210
|
+
"resource_groups": resource_sids,
|
|
211
|
+
"group_memberships": (
|
|
212
|
+
[{"sid": f"{domain_sid}-{group_rid}", "attributes": attrs,
|
|
213
|
+
"source": "account_domain"} for group_rid, attrs in normal]
|
|
214
|
+
+ [{"sid": sid, "attributes": attrs, "source": "extra"}
|
|
215
|
+
for sid, attrs in extras]
|
|
216
|
+
+ [{"sid": f"{resource_domain_sid}-{group_rid}", "attributes": attrs,
|
|
217
|
+
"source": "resource"} for resource_domain_sid, entries in domains
|
|
218
|
+
for group_rid, attrs in entries]
|
|
219
|
+
),
|
|
220
|
+
"groups": _dedupe(
|
|
221
|
+
[primary_sid] if primary_sid else [], group_sids, extra_sids, resource_sids
|
|
222
|
+
),
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def decode_delegation_info(data: bytes) -> dict:
|
|
227
|
+
"""Decode the S4U_DELEGATION_INFO target and transited service names."""
|
|
228
|
+
r = ndr_serialized_pointer(data)
|
|
229
|
+
proxy_header = r.unicode_header()
|
|
230
|
+
count, services_ref = r.u32(), r.u32()
|
|
231
|
+
if count > MAX_PAC_ENTRIES:
|
|
232
|
+
raise PACDecodeError("excessive transited services")
|
|
233
|
+
proxy = r.unicode_value(proxy_header)
|
|
234
|
+
if services_ref:
|
|
235
|
+
maximum = r.u32()
|
|
236
|
+
if maximum != count or count > (len(r.data) - r.offset) // 8:
|
|
237
|
+
raise PACDecodeError("invalid transited services array")
|
|
238
|
+
headers = [r.unicode_header() for _ in range(count)]
|
|
239
|
+
services = [r.unicode_value(header) for header in headers]
|
|
240
|
+
elif count:
|
|
241
|
+
raise PACDecodeError("null transited services pointer with nonzero count")
|
|
242
|
+
else:
|
|
243
|
+
services = []
|
|
244
|
+
return {
|
|
245
|
+
"delegation_proxy_target": proxy,
|
|
246
|
+
"delegation_transited_services": [name for name in services if name],
|
|
247
|
+
}
|
pac_decoder/ndr.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Small, strictly bounded NDR32 reader for MS-PAC structures.
|
|
2
|
+
|
|
3
|
+
This is *not* a generic DCE/RPC unmarshalling implementation. Only the
|
|
4
|
+
pointer/array/string forms used by PAC_LOGON_INFO and PAC_DEVICE_INFO are
|
|
5
|
+
supported. Incoming lengths are bounded before allocation or iteration.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import struct
|
|
11
|
+
from .binary import PACDecodeError, MAX_PAC_ENTRIES, MAX_PAC_GROUPS, MAX_SID_SUBAUTHS
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class NDRReader:
|
|
15
|
+
def __init__(self, data: bytes):
|
|
16
|
+
self.data = memoryview(data)
|
|
17
|
+
self.offset = 0
|
|
18
|
+
|
|
19
|
+
def align(self, n: int) -> None:
|
|
20
|
+
self.offset = (self.offset + n - 1) & ~(n - 1)
|
|
21
|
+
if self.offset > len(self.data):
|
|
22
|
+
raise PACDecodeError("NDR alignment exceeds buffer")
|
|
23
|
+
|
|
24
|
+
def bytes(self, size: int, alignment: int = 1) -> bytes:
|
|
25
|
+
self.align(alignment)
|
|
26
|
+
if size < 0 or size > len(self.data) - self.offset:
|
|
27
|
+
raise PACDecodeError("truncated NDR payload")
|
|
28
|
+
result = bytes(self.data[self.offset:self.offset + size])
|
|
29
|
+
self.offset += size
|
|
30
|
+
return result
|
|
31
|
+
|
|
32
|
+
def u16(self) -> int:
|
|
33
|
+
return struct.unpack("<H", self.bytes(2, 2))[0]
|
|
34
|
+
|
|
35
|
+
def u32(self) -> int:
|
|
36
|
+
return struct.unpack("<I", self.bytes(4, 4))[0]
|
|
37
|
+
|
|
38
|
+
def filetime(self) -> int:
|
|
39
|
+
low = self.u32()
|
|
40
|
+
return low | (self.u32() << 32)
|
|
41
|
+
|
|
42
|
+
def unicode_header(self) -> tuple[int, int, int]:
|
|
43
|
+
length, max_length, referent = self.u16(), self.u16(), self.u32()
|
|
44
|
+
if length & 1 or max_length & 1 or length > max_length:
|
|
45
|
+
raise PACDecodeError("invalid RPC_UNICODE_STRING length")
|
|
46
|
+
if length and not referent:
|
|
47
|
+
raise PACDecodeError("nonempty null RPC_UNICODE_STRING")
|
|
48
|
+
return length, max_length, referent
|
|
49
|
+
|
|
50
|
+
def unicode_value(self, header: tuple[int, int, int]) -> str | None:
|
|
51
|
+
length, max_length, referent = header
|
|
52
|
+
if not referent:
|
|
53
|
+
return None
|
|
54
|
+
maximum, offset, actual = self.u32(), self.u32(), self.u32()
|
|
55
|
+
if offset > maximum or actual > maximum - offset:
|
|
56
|
+
raise PACDecodeError("invalid NDR string conformance")
|
|
57
|
+
if maximum > len(self.data) // 2 or actual > len(self.data) // 2:
|
|
58
|
+
raise PACDecodeError("oversized NDR string")
|
|
59
|
+
if length > actual * 2 or length > max_length:
|
|
60
|
+
raise PACDecodeError("NDR string length mismatch")
|
|
61
|
+
raw = self.bytes(actual * 2, 2)
|
|
62
|
+
try:
|
|
63
|
+
return raw[:length].decode("utf-16-le").rstrip("\x00") or None
|
|
64
|
+
except UnicodeDecodeError as exc:
|
|
65
|
+
raise PACDecodeError("invalid NDR Unicode string") from exc
|
|
66
|
+
|
|
67
|
+
def group_array(self, referent: int, count: int) -> list[tuple[int, int]]:
|
|
68
|
+
if count > MAX_PAC_GROUPS:
|
|
69
|
+
raise PACDecodeError("excessive PAC group count")
|
|
70
|
+
if not referent:
|
|
71
|
+
if count:
|
|
72
|
+
raise PACDecodeError("null PAC group pointer with nonzero count")
|
|
73
|
+
return []
|
|
74
|
+
n = self.u32()
|
|
75
|
+
if n != count or count > (len(self.data) - self.offset) // 8:
|
|
76
|
+
raise PACDecodeError("invalid PAC group array count")
|
|
77
|
+
return [(self.u32(), self.u32()) for _ in range(count)]
|
|
78
|
+
|
|
79
|
+
def sid(self) -> str:
|
|
80
|
+
max_count = self.u32()
|
|
81
|
+
revision, count = self.bytes(2)
|
|
82
|
+
authority = int.from_bytes(self.bytes(6), "big")
|
|
83
|
+
if revision != 1 or not 1 <= count <= MAX_SID_SUBAUTHS or max_count != count:
|
|
84
|
+
raise PACDecodeError("invalid NDR RPC_SID")
|
|
85
|
+
parts = [self.u32() for _ in range(count)]
|
|
86
|
+
return f"S-{revision}-{authority}" + "".join(f"-{x}" for x in parts)
|
|
87
|
+
|
|
88
|
+
def sid_pointer(self, referent: int) -> str | None:
|
|
89
|
+
return self.sid() if referent else None
|
|
90
|
+
|
|
91
|
+
def sid_and_attributes_array(self, referent: int, count: int) -> list[tuple[str | None, int]]:
|
|
92
|
+
if count > MAX_PAC_GROUPS:
|
|
93
|
+
raise PACDecodeError("excessive extra SID count")
|
|
94
|
+
if not referent:
|
|
95
|
+
if count:
|
|
96
|
+
raise PACDecodeError("null ExtraSids pointer with nonzero count")
|
|
97
|
+
return []
|
|
98
|
+
maximum = self.u32()
|
|
99
|
+
if maximum != count or count > (len(self.data) - self.offset) // 8:
|
|
100
|
+
raise PACDecodeError("invalid ExtraSids array count")
|
|
101
|
+
entries = [(self.u32(), self.u32()) for _ in range(count)]
|
|
102
|
+
if any(not ptr for ptr, _ in entries):
|
|
103
|
+
raise PACDecodeError("null SID in ExtraSids array")
|
|
104
|
+
return [(self.sid_pointer(ptr), flags) for ptr, flags in entries]
|
|
105
|
+
|
|
106
|
+
def domain_groups_array(
|
|
107
|
+
self, referent: int, count: int
|
|
108
|
+
) -> list[tuple[str | None, list[tuple[int, int]]]]:
|
|
109
|
+
if count > MAX_PAC_ENTRIES:
|
|
110
|
+
raise PACDecodeError("excessive domain group count")
|
|
111
|
+
if not referent:
|
|
112
|
+
if count:
|
|
113
|
+
raise PACDecodeError("null domain group pointer with nonzero count")
|
|
114
|
+
return []
|
|
115
|
+
maximum = self.u32()
|
|
116
|
+
if maximum != count or count > (len(self.data) - self.offset) // 12:
|
|
117
|
+
raise PACDecodeError("invalid domain group array count")
|
|
118
|
+
entries = [(self.u32(), self.u32(), self.u32()) for _ in range(count)]
|
|
119
|
+
return [
|
|
120
|
+
(self.sid_pointer(sid_ref), self.group_array(group_ref, n))
|
|
121
|
+
for sid_ref, n, group_ref in entries
|
|
122
|
+
]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def ndr_serialized_pointer(data: bytes) -> NDRReader:
|
|
126
|
+
"""Extract a Microsoft NDR32 type-serialization envelope and root pointer."""
|
|
127
|
+
if len(data) < 20 or data[:4] != b"\x01\x10\x08\x00":
|
|
128
|
+
raise PACDecodeError("unsupported NDR type-serialization header")
|
|
129
|
+
object_length = struct.unpack_from("<I", data, 8)[0]
|
|
130
|
+
if object_length < 4 or object_length > len(data) - 16:
|
|
131
|
+
raise PACDecodeError("invalid NDR object length")
|
|
132
|
+
reader = NDRReader(data[16:16 + object_length])
|
|
133
|
+
if not reader.u32():
|
|
134
|
+
raise PACDecodeError("null NDR root pointer")
|
|
135
|
+
return reader
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: py-mspack-decoder
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Pure-Python Microsoft Kerberos PAC decoder
|
|
5
|
+
Author: Simon Fonteneau
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Requires-Python: >=3.9
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Provides-Extra: gssapi
|
|
11
|
+
Requires-Dist: gssapi>=1.8; extra == "gssapi"
|
|
12
|
+
Dynamic: license-file
|
|
13
|
+
|
|
14
|
+
# py-mspack-decoder
|
|
15
|
+
|
|
16
|
+
Pure-Python decoder for Microsoft Kerberos PAC structures, including compressed claims (LZNT1, XPRESS and XPRESS Huffman). No third-party dependencies for PAC **decoding**.
|
|
17
|
+
|
|
18
|
+
**AI-assisted development:** Most of the implementation was written with AI coding tools under human direction. It has not undergone an independent security audit.
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
## Minimal Flask + Kerberos example
|
|
23
|
+
|
|
24
|
+
See [examples/flask_gssapi.py](examples/flask_gssapi.py). It accepts an HTTP `Authorization: Negotiate` token using the service keytab, obtains a completed **Kerberos acceptor** context, and calls:
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
from pac_decoder.gssapi_helpers import decode_completed_context
|
|
28
|
+
|
|
29
|
+
pac_info = decode_completed_context(ctx)
|
|
30
|
+
print(pac_info["sid"], pac_info["groups"])
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
The helper requires a completed Kerberos acceptor context and exactly one `urn:mspac:` attribute flagged `authenticated` and `complete` by GSSAPI. It then strictly decodes the PAC. The underlying GSSAPI/Kerberos implementation, **not this package**, verifies PAC authenticity; successful authentication alone is not enough if the authenticated PAC attribute is missing.
|
|
34
|
+
|
|
35
|
+
### Run the example
|
|
36
|
+
|
|
37
|
+
The server must have a valid `HTTP/<hostname>@REALM` principal in its keytab, and MIT Kerberos must support the authenticated `urn:mspac:` attribute.
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
export KRB5_KTNAME=/path/to/http.keytab
|
|
41
|
+
export KERBEROS_HOSTNAME=service.example.com
|
|
42
|
+
flask --app examples/flask_gssapi.py run --host 127.0.0.1
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
From a Kerberos-authenticated client, over your HTTPS reverse proxy:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
curl --negotiate -u : https://service.example.com/pac
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
This is intentionally a **one-round-trip Kerberos HTTP** example. It rejects negotiations needing several GSSAPI exchanges instead of attempting to resume a lost context. Use TLS, a trusted reverse proxy and a production WSGI server for a real service. The endpoint returns identity information only as a demonstration; do not expose it publicly. PAC group membership reflects ticket issuance time, not necessarily current AD state.
|
|
52
|
+
|
|
53
|
+
## License
|
|
54
|
+
|
|
55
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
pac_decoder/__init__.py,sha256=w-ZHIgijNEefKc-9xZlpW387g8oTIxaQ2KZ60wniues,11038
|
|
2
|
+
pac_decoder/binary.py,sha256=a2Xg3alh7FLWdq21XzpO9T59aOK7-fr4mD4kmsq69SY,6301
|
|
3
|
+
pac_decoder/claims.py,sha256=cOaS_KHjMSERXQOYDdEChKpGCpNZIYLCGRxCyEp-zZc,5764
|
|
4
|
+
pac_decoder/compression.py,sha256=8u5uhXox_KiSpMVSGsp3e02rJvG16MF4OrsfnL8O_NM,8434
|
|
5
|
+
pac_decoder/gssapi_helpers.py,sha256=jmLlEiyhyqfM_wPItThZYlftMd-YWFpCSUWGsFpMZ6w,4687
|
|
6
|
+
pac_decoder/logon.py,sha256=goERew9wnMqhpPOg6FZRbQ-Xm3kjDP6a5ZcHAcyqZME,11218
|
|
7
|
+
pac_decoder/ndr.py,sha256=4FEXoAupHKKNWw3f40VFbqSwMjUI3aMPR45dK1Fc3BM,5823
|
|
8
|
+
py_mspack_decoder-0.1.1.dist-info/licenses/LICENSE,sha256=-pUD-qeYToufnRHt_nAzuNAkd1LbnqD_VIJahR5YrFw,1072
|
|
9
|
+
py_mspack_decoder-0.1.1.dist-info/METADATA,sha256=bri5YIS-0FOXf0caV-jSsGgHU4JWPkt-I3aOzvApoGk,2411
|
|
10
|
+
py_mspack_decoder-0.1.1.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
11
|
+
py_mspack_decoder-0.1.1.dist-info/top_level.txt,sha256=dzmBVQdhS1wx8GzugnotZmJgc9-Oi6PTb29S7P4HEy8,12
|
|
12
|
+
py_mspack_decoder-0.1.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Simon Fonteneau
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pac_decoder
|