From ab585931f739c8fe1c469f6f075579c92c90e0a1 Mon Sep 17 00:00:00 2001 From: gpotter2 <10530980+gpotter2@users.noreply.github.com> Date: Sun, 21 Sep 2025 23:26:57 +0200 Subject: [PATCH 01/14] Implement CMS signing/verification --- scapy/layers/kerberos.py | 26 +- scapy/layers/tls/cert.py | 418 ++++++++++++++++++++++++++++++--- scapy/layers/x509.py | 106 ++++----- test/scapy/layers/kerberos.uts | 34 ++- 4 files changed, 489 insertions(+), 95 deletions(-) diff --git a/scapy/layers/kerberos.py b/scapy/layers/kerberos.py index c8a3320d6e7..7128077c1aa 100644 --- a/scapy/layers/kerberos.py +++ b/scapy/layers/kerberos.py @@ -145,7 +145,11 @@ from scapy.layers.inet import TCP, UDP from scapy.layers.smb import _NV_VERSION from scapy.layers.smb2 import STATUS_ERREF -from scapy.layers.tls.cert import Cert, PrivKey +from scapy.layers.tls.cert import ( + Cert, + PrivKey, + CMS_Engine, +) from scapy.layers.x509 import ( _CMS_ENCAPSULATED, CMS_ContentInfo, @@ -2013,7 +2017,7 @@ def m2i(self, pkt, s): # 25: KDC_ERR_PREAUTH_REQUIRED # 36: KRB_AP_ERR_BADMATCH return MethodData(val[0].val, _underlayer=pkt), val[1] - elif pkt.errorCode.val in [6, 7, 12, 13, 18, 29, 41, 60]: + elif pkt.errorCode.val in [6, 7, 12, 13, 18, 29, 41, 60, 62]: # 6: KDC_ERR_C_PRINCIPAL_UNKNOWN # 7: KDC_ERR_S_PRINCIPAL_UNKNOWN # 12: KDC_ERR_POLICY @@ -2022,6 +2026,7 @@ def m2i(self, pkt, s): # 29: KDC_ERR_SVC_UNAVAILABLE # 41: KRB_AP_ERR_MODIFIED # 60: KRB_ERR_GENERIC + # 62: KERB_ERR_TYPE_EXTENDED try: return KERB_ERROR_DATA(val[0].val, _underlayer=pkt), val[1] except BER_Decoding_Error: @@ -2112,9 +2117,10 @@ class KRB_ERROR(ASN1_Packet): 52: "KRB_ERR_RESPONSE_TOO_BIG", 60: "KRB_ERR_GENERIC", 61: "KRB_ERR_FIELD_TOOLONG", - 62: "KDC_ERROR_CLIENT_NOT_TRUSTED", - 63: "KDC_ERROR_KDC_NOT_TRUSTED", - 64: "KDC_ERROR_INVALID_SIG", + # RFC4556 + 62: "KDC_ERR_CLIENT_NOT_TRUSTED", + 63: "KDC_ERR_KDC_NOT_TRUSTED", + 64: "KDC_ERR_INVALID_SIG", 65: "KDC_ERR_KEY_TOO_WEAK", 66: "KDC_ERR_CERTIFICATE_MISMATCH", 67: "KRB_AP_ERR_NO_TGT", @@ -2127,6 +2133,11 @@ class KRB_ERROR(ASN1_Packet): 74: "KDC_ERR_REVOCATION_STATUS_UNAVAILABLE", 75: "KDC_ERR_CLIENT_NAME_MISMATCH", 76: "KDC_ERR_KDC_NAME_MISMATCH", + 77: "KDC_ERR_INCONSISTENT_KEY_PURPOSE", + 78: "KDC_ERR_DIGEST_IN_CERT_NOT_ACCEPTED", + 79: "KDC_ERR_PA_CHECKSUM_MUST_BE_INCLUDED", + 80: "KDC_ERR_DIGEST_IN_SIGNED_DATA_NOT_ACCEPTED", + 81: "KDC_ERR_PUBLIC_KEY_ENCRYPTION_NOT_SUPPORTED", # draft-ietf-kitten-iakerb 85: "KRB_AP_ERR_IAKERB_KDC_NOT_FOUND", 86: "KRB_AP_ERR_IAKERB_KDC_NO_RESPONSE", @@ -3318,7 +3329,10 @@ def as_req(self): if self.x509: # Special PKINIT (RFC4556) factor pafactor = PADATA( - padataType=16, padataValue=PA_PK_AS_REQ() # PA-PK-AS-REQ + padataType=16, # PA-PK-AS-REQ + padataValue=PA_PK_AS_REQ( + + ), ) raise NotImplementedError("PKINIT isn't implemented yet !") else: diff --git a/scapy/layers/tls/cert.py b/scapy/layers/tls/cert.py index b38f52ca073..397c02c2e03 100644 --- a/scapy/layers/tls/cert.py +++ b/scapy/layers/tls/cert.py @@ -6,8 +6,8 @@ # 2015, 2016, 2017 Maxence Tury """ -High-level methods for PKI objects (X.509 certificates, CRLs, asymmetric keys). -Supports both RSA and ECDSA objects. +High-level methods for PKI objects (X.509 certificates, CRLs, asymmetric keys, CMS). +Supports both RSA, ECDSA and EDDSA objects. The classes below are wrappers for the ASN.1 objects defined in x509.py. For instance, here is what you could do in order to modify the subject public @@ -45,28 +45,56 @@ from scapy.config import conf, crypto_validator from scapy.error import warning from scapy.utils import binrepr -from scapy.asn1.asn1 import ASN1_BIT_STRING +from scapy.asn1.asn1 import ( + ASN1_BIT_STRING, + ASN1_NULL, + ASN1_OID, + ASN1_STRING, +) from scapy.asn1.mib import hash_by_oid +from scapy.packet import Packet from scapy.layers.x509 import ( + CMS_Attribute, + CMS_CertificateChoices, + CMS_ContentInfo, + CMS_EncapsulatedContentInfo, + CMS_IssuerAndSerialNumber, + CMS_RevocationInfoChoice, + CMS_SignedAttrsForSignature, + CMS_SignedData, + CMS_SignerInfo, ECDSAPrivateKey_OpenSSL, ECDSAPrivateKey, ECDSAPublicKey, - EdDSAPublicKey, EdDSAPrivateKey, + EdDSAPublicKey, RSAPrivateKey_OpenSSL, RSAPrivateKey, RSAPublicKey, + X509_AlgorithmIdentifier, X509_Cert, X509_CRL, X509_SubjectPublicKeyInfo, ) -from scapy.layers.tls.crypto.pkcs1 import pkcs_os2ip, _get_hash, \ - _EncryptAndVerifyRSA, _DecryptAndSignRSA -from scapy.compat import raw, bytes_encode +from scapy.layers.tls.crypto.pkcs1 import ( + _DecryptAndSignRSA, + _EncryptAndVerifyRSA, + _get_hash, + pkcs_os2ip, +) +from scapy.compat import bytes_encode + +# Typing imports +from typing import ( + List, + Optional, + Union, +) if conf.crypto_valid: from cryptography.exceptions import InvalidSignature from cryptography.hazmat.backends import default_backend + from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import rsa, ec, x25519 @@ -276,11 +304,10 @@ class PubKey(metaclass=_PubKeyFactory): def verifyCert(self, cert): """ Verifies either a Cert or an X509_Cert. """ + h = cert.getSignatureHashName() tbsCert = cert.tbsCertificate - sigAlg = tbsCert.signature - h = hash_by_oid[sigAlg.algorithm.val] - sigVal = raw(cert.signatureValue) - return self.verify(raw(tbsCert), sigVal, h=h, t='pkcs') + sigVal = bytes(cert.signatureValue) + return self.verify(bytes(tbsCert), sigVal, h=h, t='pkcs') @property def pem(self): @@ -315,6 +342,13 @@ def export(self, filename, fmt=None): elif fmt == "PEM": return f.write(self.pem.encode()) + @crypto_validator + def verify(self, msg, sig, h="sha256", **kwargs): + """ + Verify signed data. + """ + raise NotImplementedError + class PubKeyRSA(PubKey, _EncryptAndVerifyRSA): """ @@ -546,7 +580,7 @@ def signTBSCert(self, tbsCert, h="sha256"): """ sigAlg = tbsCert.signature h = h or hash_by_oid[sigAlg.algorithm.val] - sigVal = self.sign(raw(tbsCert), h=h, t='pkcs') + sigVal = self.sign(bytes(tbsCert), h=h, t='pkcs') c = X509_Cert() c.tbsCertificate = tbsCert c.signatureAlgorithm = sigAlg @@ -562,8 +596,8 @@ def verifyCert(self, cert): tbsCert = cert.tbsCertificate sigAlg = tbsCert.signature h = hash_by_oid[sigAlg.algorithm.val] - sigVal = raw(cert.signatureValue) - return self.verify(raw(tbsCert), sigVal, h=h, t='pkcs') + sigVal = bytes(cert.signatureValue) + return self.verify(bytes(tbsCert), sigVal, h=h, t='pkcs') @property def pem(self): @@ -592,6 +626,20 @@ def export(self, filename, fmt=None): elif fmt == "PEM": return f.write(self.pem.encode()) + @crypto_validator + def sign(self, data, h="sha256", **kwargs): + """ + Sign data. + """ + raise NotImplementedError + + @crypto_validator + def verify(self, msg, sig, h="sha256", **kwargs): + """ + Verify signed data. + """ + raise NotImplementedError + class PrivKeyRSA(PrivKey, _DecryptAndSignRSA): """ @@ -686,7 +734,7 @@ def fill_and_store(self, curve=None): @crypto_validator def import_from_asn1pkt(self, privkey): - self.key = serialization.load_der_private_key(raw(privkey), None, + self.key = serialization.load_der_private_key(bytes(privkey), None, backend=default_backend()) # noqa: E501 self.pubkey = PubKeyECDSA(cryptography_obj=self.key.public_key()) self.marker = "EC PRIVATE KEY" @@ -714,7 +762,7 @@ def fill_and_store(self, curve=None): @crypto_validator def import_from_asn1pkt(self, privkey): - self.key = serialization.load_der_private_key(raw(privkey), None, + self.key = serialization.load_der_private_key(bytes(privkey), None, backend=default_backend()) # noqa: E501 self.pubkey = PubKeyECDSA(cryptography_obj=self.key.public_key()) self.marker = "PRIVATE KEY" @@ -771,7 +819,6 @@ def import_from_asn1pkt(self, cert): self.x509Cert = cert tbsCert = cert.tbsCertificate - self.tbsCertificate = tbsCert if tbsCert.version: self.version = tbsCert.version.val + 1 @@ -801,7 +848,7 @@ def import_from_asn1pkt(self, cert): raise Exception(error_msg) self.notAfter_str_simple = time.strftime("%x", self.notAfter) - self.pubKey = PubKey(raw(tbsCert.subjectPublicKeyInfo)) + self.pubKey = PubKey(bytes(tbsCert.subjectPublicKeyInfo)) if tbsCert.extensions: for extn in tbsCert.extensions: @@ -816,7 +863,7 @@ def import_from_asn1pkt(self, cert): elif extn.extnID.oidname == "authorityKeyIdentifier": self.authorityKeyID = extn.extnValue.keyIdentifier.val - self.signatureValue = raw(cert.signatureValue) + self.signatureValue = bytes(cert.signatureValue) self.signatureLen = len(self.signatureValue) def isIssuerCert(self, other): @@ -846,14 +893,19 @@ def encrypt(self, msg, t="pkcs", h="sha256", mgf=None, L=None): def verify(self, msg, sig, t="pkcs", h="sha256", mgf=None, L=None): return self.pubKey.verify(msg, sig, t=t, h=h, mgf=mgf, L=L) - def getSignatureHash(self): + def getSignatureHashName(self): """ - Return the hash used by the 'signatureAlgorithm' + Return the hash name used by the 'signatureAlgorithm'. """ tbsCert = self.tbsCertificate sigAlg = tbsCert.signature - h = hash_by_oid[sigAlg.algorithm.val] - return _get_hash(h) + return hash_by_oid[sigAlg.algorithm.val] + + def getSignatureHash(self): + """ + Return the hash cryptography object used by the 'signatureAlgorithm' + """ + return _get_hash(self.getSignatureHashName()) def setSubjectPublicKeyFromPrivateKey(self, key): """ @@ -939,6 +991,10 @@ def isRevoked(self, crl_list): return self.serial in (x[0] for x in c.revoked_cert_serials) return False + @property + def tbsCertificate(self): + return self.x509Cert.tbsCertificate + @property def pem(self): return der2pem(self.der, self.marker) @@ -1004,7 +1060,7 @@ def import_from_asn1pkt(self, crl): self.x509CRL = crl tbsCertList = crl.tbsCertList - self.tbsCertList = raw(tbsCertList) + self.tbsCertList = bytes(tbsCertList) if tbsCertList.version: self.version = tbsCertList.version.val + 1 @@ -1057,7 +1113,7 @@ def import_from_asn1pkt(self, crl): revoked.append((serial, date)) self.revoked_cert_serials = revoked - self.signatureValue = raw(crl.signatureValue) + self.signatureValue = bytes(crl.signatureValue) self.signatureLen = len(self.signatureValue) def isIssuerCert(self, other): @@ -1084,14 +1140,18 @@ def show(self): class Chain(list): """ - Basically, an enhanced array of Cert. + An enhanced array of Cert. """ - def __init__(self, certList, cert0=None): + def __init__( + self, + certList: Union[List[Cert], str], + cert0: Union[Cert, str, None] = None, + ): """ - Construct a chain of certificates starting with a self-signed - certificate (or any certificate submitted by the user) - and following issuer/subject matching and signature validity. + Construct a chain of certificates that follows issuer/subject matching and + respects signature validity. + If there is exactly one chain to be constructed, it will be, but if there are multiple potential chains, there is no guarantee that the retained one will be the longest one. @@ -1100,8 +1160,39 @@ def __init__(self, certList, cert0=None): Note that we do not check AKID/{SKID/issuer/serial} matching, nor the presence of keyCertSign in keyUsage extension (if present). + + :param certList: either a list of certificates, or a path to a file containing + a list of certificates. + :param cert0: if provided, force the ROOT CA of the chain. """ - list.__init__(self, ()) + super(Chain, self).__init__(()) + + # Parse the certificate list / CA + if isinstance(certList, str): + # It's a path. First get the _PKIObj + obj = _PKIObjMaker.__call__(Chain, certList, _MAX_CERT_SIZE, + "CERTIFICATE") + + # Then parse the der until there's nothing left + certList = [] + payload = obj._der + while payload: + cert = X509_Cert(payload) + if conf.raw_layer in cert.payload: + payload = cert.payload.load + else: + payload = None + cert.remove_payload() + certList.append(Cert(cert)) + + self.frmt = obj.frmt + else: + self.frmt = "PEM" + + if isinstance(cert0, str): + cert0 = Cert(cert0) + + # Find the ROOT CA if cert0: self.append(cert0) else: @@ -1111,7 +1202,8 @@ def __init__(self, certList, cert0=None): certList.remove(root_candidate) break - if len(self) > 0: + # Build the chain + if self: while certList: tmp_len = len(self) for c in certList: @@ -1197,6 +1289,38 @@ def verifyChainFromCAPath(self, capath, untrusted_file=None): return self.verifyChain(anchors, untrusted) + def findCertByIssuer(self, issuer): + """ + Find a certificate in the chain by issuer. + """ + for cert in self: + if cert.issuer == issuer: + return cert + raise KeyError("Certificate not found !") + + def export(self, filename, fmt=None): + """ + Export a chain of certificates 'fmt' format (DER or PEM) to file 'filename' + """ + if fmt is None: + if filename.endswith(".pem"): + fmt = "PEM" + else: + fmt = "DER" + with open(filename, "wb") as f: + if fmt == "DER": + return f.write(self.der) + elif fmt == "PEM": + return f.write(self.pem.encode()) + + @property + def der(self): + return b"".join(x.der for x in self) + + @property + def pem(self): + return "".join(x.pem for x in self) + def __repr__(self): llen = len(self) - 1 if llen < 0: @@ -1215,3 +1339,233 @@ def __repr__(self): s += "\n" idx += 1 return s + + +####### +# CMS # +####### + +# RFC3852 + + +class CMS_Engine: + """ + A utility class to perform CMS/PKCS7 operations, as specified by RFC3852. + + :param chain: a certificates chain to sign or validate messages against. + :param crls: a list of CRLs to include. This is currently not checked. + """ + + def __init__( + self, + chain: Chain, + crls: List[X509_CRL] = [], + ): + self.chain = chain + self.crls = crls + + def sign( + self, + message: Union[bytes, Packet], + eContentType: ASN1_OID, + cert: Cert, + key: PrivKey, + h: Optional[str] = None, + ): + """ + Sign a message using CMS. + + :param message: the inner content to sign. + :param eContentType: the OID of the inner content. + :param cert: the certificate whose key to use use for signing. + :param key: the private key to use for signing. + :param h: the hash to use (default: same as the certificate's signature) + + We currently only support X.509 certificates ! + """ + # RFC3852 sect 5.1 - SignedData Type version + if self.chain: + version = 3 + else: + version = 1 + + # RFC3852 - 5.4. Message Digest Calculation Process + h = h or cert.getSignatureHashName() + hash = hashes.Hash(_get_hash(h)) + hash.update(bytes(message)) + hashed_message = hash.finalize() + + # 5.5. Signature Generation Process + signerInfo = CMS_SignerInfo( + version=1, + sid=CMS_IssuerAndSerialNumber( + issuer=cert.tbsCertificate.issuer, + serialNumber=cert.tbsCertificate.serialNumber, + ), + digestAlgorithm=X509_AlgorithmIdentifier( + algorithm=ASN1_OID(h), + parameters=ASN1_NULL(0), + ), + signedAttrs=[ + CMS_Attribute( + attrType=ASN1_OID("contentType"), + attrValues=[ + eContentType, + ] + ), + CMS_Attribute( + attrType=ASN1_OID("messageDigest"), + # "A message-digest attribute MUST have a single attribute value" + attrValues=[ + ASN1_STRING(hashed_message), + ] + ) + ], + signatureAlgorithm=cert.tbsCertificate.signature, + ) + signerInfo.signature = ASN1_STRING( + key.sign( + bytes( + CMS_SignedAttrsForSignature( + signedAttrs=signerInfo.signedAttrs, + ) + ), + h=h, + ) + ) + + # Build a list of X509_Cert to ship (no ROOT certificate) + certificates = [ + x for x in + self.chain + if not x.isSelfSigned() + ] + if cert.x509Cert not in certificates: + certificates.append(cert.x509Cert) + + # Build final structure + return CMS_ContentInfo( + contentType=ASN1_OID("id-signedData"), + content=CMS_SignedData( + version=version, + digestAlgorithms=X509_AlgorithmIdentifier( + algorithm=ASN1_OID(h), + parameters=ASN1_NULL(0), + ), + encapContentInfo=CMS_EncapsulatedContentInfo( + eContentType=eContentType, + eContent=message, + ), + certificates=( + [ + CMS_CertificateChoices( + certificate=cert + ) + for cert in certificates + ] if certificates else None + ), + crls=( + [ + CMS_RevocationInfoChoice( + crl=crl + ) + for crl in self.crls + ] if self.crls else None + ), + signerInfos=[ + signerInfo, + ], + ) + ) + + def verify( + self, + contentInfo: CMS_ContentInfo, + eContentType: Optional[ASN1_OID] = None, + ): + """ + Verify a CMS message against the list of trusted certificates, + and return the unpacked message if the verification succeeds. + + :param contentInfo: the ContentInfo whose signature to verify + :param eContentType: if provided, verifies that the content type is valid + """ + if contentInfo.contentType.oidname != "id-signedData": + raise ValueError("ContentInfo isn't signed !") + + signeddata = contentInfo.content + + # Build the certificate chain + certificates = [ + Cert(x.certificate) + for x in signeddata.certificates + ] + chain = Chain(self.chain + certificates) + + # Check there's at least one signature + if not signeddata.signerInfos: + raise ValueError("ContentInfo contained no signature !") + + # Check all signatures + for signerInfo in signeddata.signerInfos: + # Find certificate in the chain that did this + cert: Cert = chain.findCertByIssuer(signerInfo.sid.get_issuer()) + + # Verify the message hash + if signerInfo.signedAttrs: + # Verify the contentType + try: + contentType = next( + x.attrValues[0] + for x in signerInfo.signedAttrs + if x.attrType.oidname == "contentType" + ) + + if contentType != signeddata.encapContentInfo.eContentType: + raise ValueError("Inconsistent 'contentType' was detected in packet !") + + if eContentType is not None and eContentType != contentType: + raise ValueError("Expected '%s' but got '%s' contentType !" % ( + eContentType, + contentType, + )) + except StopIteration: + raise ValueError("Missing contentType in signedAttrs !") + + # Verify the messageDigest value + try: + # "A message-digest attribute MUST have a single attribute value" + messageDigest = next( + x.attrValues[0].val + for x in signerInfo.signedAttrs + if x.attrType.oidname == "messageDigest" + ) + + # Re-calculate hash + h = signerInfo.digestAlgorithm.algorithm.oidname + hash = hashes.Hash(_get_hash(h)) + hash.update(bytes(signeddata.encapContentInfo.eContent)) + hashed_message = hash.finalize() + + if hashed_message != messageDigest: + raise ValueError("Invalid messageDigest value !") + except StopIteration: + raise ValueError("Missing messageDigest in signedAttrs !") + + # Verify the signature + cert.verify( + msg=bytes( + CMS_SignedAttrsForSignature( + signedAttrs=signerInfo.signedAttrs, + ) + ), + sig=signerInfo.signature.val, + ) + else: + cert.verify( + msg=bytes(signeddata.encapContentInfo), + sig=signerInfo.signature.val, + ) + + # Return the content + return signeddata.encapContentInfo.eContent diff --git a/scapy/layers/x509.py b/scapy/layers/x509.py index 1f1afe4f9c9..cf031cc70c4 100644 --- a/scapy/layers/x509.py +++ b/scapy/layers/x509.py @@ -7,7 +7,7 @@ # Cool history about this file: http://natisbad.org/scapy/index.html """ -X.509 certificates and other crypto-related ASN.1 structures +X.509 certificates, OCSP, CRL, CMS and other crypto-related ASN.1 structures """ from scapy.asn1.mib import conf # loads conf.mib @@ -136,7 +136,8 @@ class DomainParameters(ASN1_Packet): ASN1_root = ASN1F_SEQUENCE( ASN1F_INTEGER("p", 0), ASN1F_INTEGER("g", 0), - ASN1F_INTEGER("q", 0), + # BUG: 'q' isn't supposed to be optional, yet Windows skipts it sometimes... + ASN1F_optional(ASN1F_INTEGER("q", 0)), ASN1F_optional(ASN1F_INTEGER("j", 0)), ASN1F_optional( ASN1F_PACKET("validationParms", None, ValidationParms), @@ -969,6 +970,33 @@ class ECDSAPrivateKey_OpenSSL(Packet): ] +class _IssuerUtils: + def get_issuer(self): + attrs = self.issuer + attrsDict = {} + for attr in attrs: + # we assume there is only one name in each rdn ASN1_SET + attrsDict[attr.rdn[0].type.oidname] = plain_str(attr.rdn[0].value.val) # noqa: E501 + return attrsDict + + def get_issuer_str(self): + """ + Returns a one-line string containing every type/value + in a rather specific order. sorted() built-in ensures unicity. + """ + name_str = "" + attrsDict = self.get_issuer() + for attrType, attrSymbol in _attrName_mapping: + if attrType in attrsDict: + name_str += "/" + attrSymbol + "=" + name_str += attrsDict[attrType] + for attrType in sorted(attrsDict): + if attrType not in _attrName_specials: + name_str += "/" + attrType + "=" + name_str += attrsDict[attrType] + return name_str + + class X509_Validity(ASN1_Packet): ASN1_codec = ASN1_Codecs.BER ASN1_root = ASN1F_SEQUENCE( @@ -991,7 +1019,7 @@ class X509_Validity(ASN1_Packet): _attrName_specials = [name for name, symbol in _attrName_mapping] -class X509_TBSCertificate(ASN1_Packet): +class X509_TBSCertificate(ASN1_Packet, _IssuerUtils): ASN1_codec = ASN1_Codecs.BER ASN1_root = ASN1F_SEQUENCE( ASN1F_optional( @@ -1021,31 +1049,6 @@ class X509_TBSCertificate(ASN1_Packet): X509_Extension, explicit_tag=0xa3))) - def get_issuer(self): - attrs = self.issuer - attrsDict = {} - for attr in attrs: - # we assume there is only one name in each rdn ASN1_SET - attrsDict[attr.rdn[0].type.oidname] = plain_str(attr.rdn[0].value.val) # noqa: E501 - return attrsDict - - def get_issuer_str(self): - """ - Returns a one-line string containing every type/value - in a rather specific order. sorted() built-in ensures unicity. - """ - name_str = "" - attrsDict = self.get_issuer() - for attrType, attrSymbol in _attrName_mapping: - if attrType in attrsDict: - name_str += "/" + attrSymbol + "=" - name_str += attrsDict[attrType] - for attrType in sorted(attrsDict): - if attrType not in _attrName_specials: - name_str += "/" + attrType + "=" - name_str += attrsDict[attrType] - return name_str - def get_subject(self): attrs = self.subject attrsDict = {} @@ -1105,7 +1108,7 @@ class X509_RevokedCertificate(ASN1_Packet): None, X509_Extension))) -class X509_TBSCertList(ASN1_Packet): +class X509_TBSCertList(ASN1_Packet, _IssuerUtils): ASN1_codec = ASN1_Codecs.BER ASN1_root = ASN1F_SEQUENCE( ASN1F_optional( @@ -1125,31 +1128,6 @@ class X509_TBSCertList(ASN1_Packet): X509_Extension, explicit_tag=0xa0))) - def get_issuer(self): - attrs = self.issuer - attrsDict = {} - for attr in attrs: - # we assume there is only one name in each rdn ASN1_SET - attrsDict[attr.rdn[0].type.oidname] = plain_str(attr.rdn[0].value.val) # noqa: E501 - return attrsDict - - def get_issuer_str(self): - """ - Returns a one-line string containing every type/value - in a rather specific order. sorted() built-in ensures unicity. - """ - name_str = "" - attrsDict = self.get_issuer() - for attrType, attrSymbol in _attrName_mapping: - if attrType in attrsDict: - name_str += "/" + attrSymbol + "=" - name_str += attrsDict[attrType] - for attrType in sorted(attrsDict): - if attrType not in _attrName_specials: - name_str += "/" + attrType + "=" - name_str += attrsDict[attrType] - return name_str - class ASN1F_X509_CRL(ASN1F_SEQUENCE): def __init__(self, **kargs): @@ -1213,6 +1191,11 @@ class CMS_EncapsulatedContentInfo(ASN1_Packet): ASN1F_optional( _EncapsulatedContent_Field("eContent", None, explicit_tag=0xA0), + ), + # BUG: some Windows versions incorrectly use an implicit octet string. + ASN1F_optional( + _EncapsulatedContent_Field("_eContent", None, + implicit_tag=0xA0), ) ) @@ -1241,10 +1224,10 @@ class CMS_CertificateChoices(ASN1_Packet): # RFC3852 sect 10.2.4 -class CMS_IssuerAndSerialNumber(ASN1_Packet): +class CMS_IssuerAndSerialNumber(ASN1_Packet, _IssuerUtils): ASN1_codec = ASN1_Codecs.BER ASN1_root = ASN1F_SEQUENCE( - ASN1F_PACKET("issuer", X509_DirectoryName(), X509_DirectoryName), + ASN1F_SEQUENCE_OF("issuer", _default_issuer, X509_RDN), ASN1F_INTEGER("serialNumber", 0) ) @@ -1289,6 +1272,17 @@ class CMS_SignerInfo(ASN1_Packet): ) +# RFC3852 sect 5.4 + +class CMS_SignedAttrsForSignature(ASN1_Packet): + ASN1_codec = ASN1_Codecs.BER + ASN1_root = ASN1F_SET_OF( + "signedAttrs", + None, + CMS_Attribute, + ) + + # RFC3852 sect 5.1 class CMS_SignedData(ASN1_Packet): diff --git a/test/scapy/layers/kerberos.uts b/test/scapy/layers/kerberos.uts index 0080964f995..36224702ecd 100644 --- a/test/scapy/layers/kerberos.uts +++ b/test/scapy/layers/kerberos.uts @@ -201,7 +201,8 @@ assert pk_preauth.trustedCertifiers[0].subjectName.directoryName[2].rdn[0].type. assert pk_preauth.trustedCertifiers[0].subjectName.directoryName[2].rdn[0].value.val == b"DOMAIN-DC1-CA" assert pk_preauth.trustedCertifiers[0].issuerAndSerialNumber.serialNumber.val == 142762589450708598374370602088381230866 -authpack = pk_preauth.signedAuthpack.content.encapContentInfo.eContent +signedauthpack = pk_preauth.signedAuthpack +authpack = signedauthpack.content.encapContentInfo.eContent assert [x.algorithm.oidname for x in authpack.supportedCMSTypes] == [ 'ecdsa-with-SHA512', 'ecdsa-with-SHA256', @@ -214,6 +215,37 @@ assert authpack.pkAuthenticator.freshnessToken is None assert authpack.pkAuthenticator.paChecksum2.checksum.val.hex() == "5aeb03e889e99fcd6c205ef484b9dd7b462b9e94c3fe68b115a71cd287fcd775" assert authpack.pkAuthenticator.paChecksum2.algorithmIdentifier.algorithm.oidname == "sha256" += PKINIT - Verify CMS signature and extract + +from scapy.layers.tls.cert import Cert, PrivKey, Chain, CMS_Engine + +# Get root CA +ca = Cert(bytes.fromhex('3082036930820251a00302010202106b671318bb858b8e437e4229b0d32f12300d06092a864886f70d01010b0500304731153013060a0992268993f22c64011916054c4f43414c31163014060a0992268993f22c6401191606444f4d41494e311630140603550403130d444f4d41494e2d4443312d4341301e170d3235303932303232313034365a170d3330303932303232323034365a304731153013060a0992268993f22c64011916054c4f43414c31163014060a0992268993f22c6401191606444f4d41494e311630140603550403130d444f4d41494e2d4443312d434130820122300d06092a864886f70d01010105000382010f003082010a0282010100d502f47f909c951c87f2e8e6ac1c6f86d555b3311e5ef6086b588fb5eeb66277f63d18f04e65ba07570999bcc7cca3e0fa70914fcfa8acd81d4fbf4bb570a089b1b897cf3e07abc9fa75417bcb7171aaa95e20df12add93fada7df5447210820c1de12e356b248b7fe169019b7cf254c5be50571da26ff4219b8680fa249c14673bf743ef37b46c740353cb88097a099fbc7ca41a79c2cd9bc3a663003edfd12678c88b3970fdc211e38b985d6795d57041de0f3182873670bfee903069f59d3f0ff1634bf57f122ef7d1511775c47fdc574f632c9a1e8af305c81077af542f5499977870d8b0bce0d1fd8088636814d7847e0863ceb0ebe8bb0bd4e47eed01d0203010001a351304f300b0603551d0f040403020186300f0603551d130101ff040530030101ff301d0603551d0e04160414ab14d5ae948281f079726970b3b8f97003aa760c301006092b06010401823715010403020100300d06092a864886f70d01010b05000382010100763c9c93d6f0dd98d6ee5269f1d5f8b83fa14e62a9513806f6f978769208ff65f263f1809743f42b6b70cc77f93f5278e62e4d1da2ae5285e8da155951aa5207cea519d373a202d889e37a9fdde6c79e7a574d2dacd3ea695fde5980d16f91b14cd8f3944cc6a5d3d4c5d95e12f863857fe733285ac04d43fdb0ee52dc8ae5c8d1dd6e32405df2f835bd1681dbf5af9fc523cfe31c31fcde16a07f90733f48cff0392a0a18a1787b91d6b67441d78f507043acfb99c64eebc77717a21cf85ec160411a8f8244f8ef493ad22e5bbdb73d647fc6d911b040d373740b11fa65df5f2a8087ae63f69da5fc14e2e320f6d3e013d319a15762ec6ee2eb3cdf9763a523')) + +# Build CMS engine to verify the authpack +cms = CMS_Engine(Chain([ca])) + +# Verify signature +authpack = cms.verify(signedauthpack, ASN1_OID('id-pkinit-authData')) +assert isinstance(authpack, AuthPack) + += PKINIT - Resign AuthPack and re-verify signature + +# Get cert/key +cert = Cert(bytes.fromhex('3082062030820508a00302010202131b000000028b4c5c90b3392fca000000000002300d06092a864886f70d01010b0500304731153013060a0992268993f22c64011916054c4f43414c31163014060a0992268993f22c6401191606444f4d41494e311630140603550403130d444f4d41494e2d4443312d4341301e170d3235303932303232313135385a170d3236303932303232313135385a305731153013060a0992268993f22c64011916054c4f43414c31163014060a0992268993f22c6401191606444f4d41494e310e300c060355040313055573657273311630140603550403130d41646d696e6973747261746f7230820122300d06092a864886f70d01010105000382010f003082010a02820101009edc4865105bdbe4843dcb43a1ed273630d4bb84e2c6096cb8ef4d111da3dfc8ad78ff7a02a6ea6da16f2ecd0a7e4a85c7b685b02286298493834f8361a318864bea2f2faa92a3236cd1e373eb2874ff8e09468762de9af0a0881ea098fbeadccb9573e53c90da8398a9992e6e6a46081e23c31527453f9540ab4bca93d7b139a97c3a0392d8c035832005cc1ae2fdbfe098381e62b37cd6b94ea638fd06d2e2dfb4c1c35896d717188fa8c472a42aaf65c04ff1f2a55dbb0b02dcec1f9e07d7dd930ddec43947cf229324bfa5189bfc5a34a59864c95fa2351b506979cf1bc3529a7933be0f2004932490d1a250735bd692af367f5ca326d392c28c99bde1210203010001a38202f3308202ef301706092b0601040182371402040a1e08005500730065007230290603551d2504223020060a2b0601040182370a030406082b0601050507030406082b06010505070302300e0603551d0f0101ff0404030205a0304406092a864886f70d01090f04373035300e06082a864886f70d030202020080300e06082a864886f70d030402020080300706052b0e030207300a06082a864886f70d0307301d0603551d0e041604140a63d8a405fe59c3f3abbef3111f6f6a6a08a973301f0603551d23041830168014ab14d5ae948281f079726970b3b8f97003aa760c3081c80603551d1f0481c03081bd3081baa081b7a081b48681b16c6461703a2f2f2f434e3d444f4d41494e2d4443312d43412c434e3d4443312c434e3d4344502c434e3d5075626c69632532304b657925323053657276696365732c434e3d53657276696365732c434e3d436f6e66696775726174696f6e2c44433d444f4d41494e2c44433d4c4f43414c3f63657274696669636174655265766f636174696f6e4c6973743f626173653f6f626a656374436c6173733d63524c446973747269627574696f6e506f696e743081c006082b060105050701010481b33081b03081ad06082b060105050730028681a06c6461703a2f2f2f434e3d444f4d41494e2d4443312d43412c434e3d4149412c434e3d5075626c69632532304b657925323053657276696365732c434e3d53657276696365732c434e3d436f6e66696775726174696f6e2c44433d444f4d41494e2c44433d4c4f43414c3f634143657274696669636174653f626173653f6f626a656374436c6173733d63657274696669636174696f6e417574686f7269747930350603551d11042e302ca02a060a2b060104018237140203a01c0c1a41646d696e6973747261746f7240444f4d41494e2e4c4f43414c304e06092b06010401823719020441303fa03d060a2b060104018237190201a02f042d532d312d352d32312d313332323235373836362d343033353133333636322d313134303736393232322d353030300d06092a864886f70d01010b050003820101005b76869c48c9e4f28043253b8552a6017dc25f9dc990da86a79210f334c1a7e50b6125ab176bc7bb194b96a02736c9838117071d533e99467bf24219228bb40b6d410c8fb23f129010b68777acb83944842a0af694673206be22c0a0078ee0543962b31bae8d809ef553dbe858cd063a7a06f1ea7d026394ace39f294ad5d8c1b077e58e7d17f86eea918aa88ac09cf55ffcf147aa14a4c64f4216211e45fd8794b2906a29b97bcbd47a0b213768f5403f9aa08fd23ea92664fb9a0246ae75e34f939102fad7c48b8c5bb650203aa48b48bed4635bff4e3386e694d57a4e7e65939c5a5a72997176b5d0e50bd369e78bbf0cda53db204fbf37839223daff3a06')) +key = PrivKey(bytes.fromhex('308204bd020100300d06092a864886f70d0101010500048204a7308204a302010002820101009edc4865105bdbe4843dcb43a1ed273630d4bb84e2c6096cb8ef4d111da3dfc8ad78ff7a02a6ea6da16f2ecd0a7e4a85c7b685b02286298493834f8361a318864bea2f2faa92a3236cd1e373eb2874ff8e09468762de9af0a0881ea098fbeadccb9573e53c90da8398a9992e6e6a46081e23c31527453f9540ab4bca93d7b139a97c3a0392d8c035832005cc1ae2fdbfe098381e62b37cd6b94ea638fd06d2e2dfb4c1c35896d717188fa8c472a42aaf65c04ff1f2a55dbb0b02dcec1f9e07d7dd930ddec43947cf229324bfa5189bfc5a34a59864c95fa2351b506979cf1bc3529a7933be0f2004932490d1a250735bd692af367f5ca326d392c28c99bde12102030100010282010075a71d72c407d4364cfe5b010ef6cdb8a3b799dd93fa2956bd2c75be3c5e76c9703891b5322b9ea96d0b23f535554d2a013c1b8cd434daa0d68344ab3fef83a54aa9f9226b48c8cbdeb71fa6653e045094482854f2937cdac379ac7d3270388427bedb23a6947d51430a3069a3dacf5d09bd60a8d4f9c35a6d97afbd2b7b6e43e46458433c45c75b87d85830547fd8bfe5ba9119be096833c660b3f4395296a10d2bcdb17ac22d9566aeb602656b715ece5401ef3f4f4731bcbb5316b38a881531a94e36807cc2ef6311e876b41c4fc1053c0d221ad5150ac52b1645aeb6a89861dcbb7faff3350cfa2027a6042681c692ffa3a3a54ef45dc51dadeb132086a502818100d1969cbf231b1e1a73d611fc6d6c60504ccf8c161c49b63b3d40adaeee6540d402c29dfa7f0538a2a4d8870b8bb3e04066423dbdefadf8eadcc9d4bfc2d30654d382eaa70be32fa108ff1bb816abb224d99fffc21cae781fd1637045b7e533614691f42b026ee83dc492e21271bf2fd65e34b4fb31ad522f1e64dc8eaa62b59f02818100c209f373b928c87ae60089f258ee4710983cfcd5586df3aa3bdbb46bf7357681c293328500fafb7daf9ad0c41cf17d3801136424cdee252f036a8033755959f6ba4d5207402619e35f8bc1cd41956d1f921b5b814ffbe4571a1da43007e9ab34b38224cbe98b713a968755e7b956a93dd9ee335888b79a9d4ef9ee2711b8713f0281807a131ba148b556c75988ea58f8f312f6328700b5302ccef39a2dbdfc11e6efe78ce406580cfbe18cfa2f141969798fb872d74a5702ef75f8763928adb8b06913a74ead96369a50f79ee1d827552d1449da6812f3e0f8ce06da52ece5eec29536a7800393b98b17c24268bb3cbafbfcc50381f79807cb47ff21d8e58e4337d3490281803c8da66fe2c49b6bdf032409813f3ae62edc397acad1e54ca6c975908be11f4e774e4061c96089c33b5df0f082a7ca100425ed069f4d464559a78ec28048960ead2d1c002f40b4ab8451b4f53d1648aba588ec117ac87d05c19ca67466c3c12dfd270c1ca69161908b1148f9bb9913cfbd86dc7730933ba903d07345b5fdfd3902818100852917f4d9244d06f54572f7c837069bfb3541e420444315cf3759d65d038d45135869c3bd97ab02c9697cdc971eaef6d5089adce124d69862d6040dbffb13d08b97f2b2ba74a673c6a3d327e07aeece4c72de22844ffdc5d989308552ca0d324c381fbdb8675f8408f26200dcd8c756778b46a80fcea2b60ba3017380871ba4')) + +# Resign +signed = cms.sign( + authpack, + ASN1_OID('id-pkinit-authData'), + cert, + key, +) + +authpack = cms.verify(signed, ASN1_OID('id-pkinit-authData')) +assert isinstance(authpack, AuthPack) + = PKINIT - Parse AS-REP with CMS structures (MIT Kerberos) from scapy.layers.tls.cert import Cert From f8e39d5c03dd2383cb1cf6ea2910af812de895f6 Mon Sep 17 00:00:00 2001 From: gpotter2 <10530980+gpotter2@users.noreply.github.com> Date: Thu, 25 Sep 2025 13:29:29 +0200 Subject: [PATCH 02/14] Implement PKINIT in AS-REQ + Improve SPNEGOSSP getter --- scapy/asn1/mib.py | 11 +- scapy/layers/kerberos.py | 468 +++++++++++++++++++++++++-------- scapy/layers/smbclient.py | 2 +- scapy/layers/spnego.py | 17 +- scapy/layers/tls/cert.py | 407 ++++++++++++++++------------ scapy/layers/x509.py | 64 ++++- scapy/libs/rfc3961.py | 32 +++ scapy/modules/ticketer.py | 6 + test/configs/cryptography.utsc | 1 - test/scapy/layers/kerberos.uts | 112 +++++++- test/scapy/layers/tls/cert.uts | 100 ------- 11 files changed, 816 insertions(+), 404 deletions(-) diff --git a/scapy/asn1/mib.py b/scapy/asn1/mib.py index 029f8281225..900a6ab1acc 100644 --- a/scapy/asn1/mib.py +++ b/scapy/asn1/mib.py @@ -284,16 +284,24 @@ def load_mib(filenames): "1.3.101.113": "Ed448", } +# pkcs3 # + +pkcs3_oids = { + "1.2.840.113549.1.3": "pkcs-3", + "1.2.840.113549.1.3.1": "dhKeyAgreement", +} + # pkcs7 # pkcs7_oids = { + "1.2.840.113549.1.7": "pkcs-7", "1.2.840.113549.1.7.2": "id-signedData", } # pkcs9 # pkcs9_oids = { - "1.2.840.113549.1.9": "pkcs9", + "1.2.840.113549.1.9": "pkcs-9", "1.2.840.113549.1.9.0": "modules", "1.2.840.113549.1.9.1": "emailAddress", "1.2.840.113549.1.9.2": "unstructuredName", @@ -724,6 +732,7 @@ def load_mib(filenames): secsig_oids, nist_oids, thawte_oids, + pkcs3_oids, pkcs7_oids, pkcs9_oids, attributeType_oids, diff --git a/scapy/layers/kerberos.py b/scapy/layers/kerberos.py index 7128077c1aa..a4485a14ab2 100644 --- a/scapy/layers/kerberos.py +++ b/scapy/layers/kerberos.py @@ -63,11 +63,12 @@ ASN1_BIT_STRING, ASN1_BOOLEAN, ASN1_Class, + ASN1_Codecs, ASN1_GENERAL_STRING, ASN1_GENERALIZED_TIME, ASN1_INTEGER, + ASN1_OID, ASN1_STRING, - ASN1_Codecs, ) from scapy.asn1fields import ( ASN1F_BIT_STRING_ENCAPS, @@ -147,9 +148,18 @@ from scapy.layers.smb2 import STATUS_ERREF from scapy.layers.tls.cert import ( Cert, - PrivKey, + CertList, + CertTree, CMS_Engine, + PrivKey, ) +from scapy.layers.tls.crypto.hash import ( + Hash_SHA, + Hash_SHA256, + Hash_SHA384, + Hash_SHA512, +) +from scapy.layers.tls.crypto.groups import _ffdh_groups from scapy.layers.x509 import ( _CMS_ENCAPSULATED, CMS_ContentInfo, @@ -158,17 +168,37 @@ X509_AlgorithmIdentifier, X509_DirectoryName, X509_SubjectPublicKeyInfo, + DomainParameters, ) # Redirect exports from RFC3961 try: from scapy.libs.rfc3961 import * # noqa: F401,F403 + from scapy.libs.rfc3961 import ( + _rfc1964pad, + ChecksumType, + Cipher, + decrepit_algorithms, + EncryptionType, + Hmac_MD5, + Key, + KRB_FX_CF2, + octetstring2key, + ) except ImportError: pass + +# Crypto imports +if conf.crypto_valid: + from cryptography.hazmat.primitives.serialization import pkcs12 + from cryptography.hazmat.primitives.asymmetric import dh + # Typing imports from typing import ( + List, Optional, + Union, ) @@ -454,8 +484,6 @@ class EncryptionKey(ASN1_Packet): ) def toKey(self): - from scapy.libs.rfc3961 import Key - return Key( etype=self.keytype.val, key=self.keyvalue.val, @@ -523,7 +551,7 @@ def get_usage(self): def verify(self, key, text, key_usage_number=None): """ - Decrypt and return the data contained in cipher. + Verify a signature of text using a key. :param key: the key to use to check the checksum :param text: the bytes to verify @@ -536,7 +564,7 @@ def verify(self, key, text, key_usage_number=None): def make(self, key, text, key_usage_number=None, cksumtype=None): """ - Encrypt text and set it into cipher. + Make a signature. :param key: the key to use to make the checksum :param text: the bytes to make a checksum of @@ -1252,7 +1280,7 @@ class PA_PK_AS_REQ(ASN1_Packet): ASN1F_optional( ASN1F_SEQUENCE_OF( "trustedCertifiers", - [ExternalPrincipalIdentifier()], + None, ExternalPrincipalIdentifier, explicit_tag=0xA1, ), @@ -1281,11 +1309,59 @@ class PAChecksum2(ASN1_Packet): ), ) + def verify(self, text): + """ + Verify a checksum of text. + + :param text: the bytes to verify + """ + # [MS-PKCA] 2.2.3 - PAChecksum2 + + # Only some OIDs are supported. Dumb but readable code. + oid = self.algorithmIdentifier.algorithm.val + if oid == "1.3.14.3.2.26": + hashcls = Hash_SHA + elif oid == "2.16.840.1.101.3.4.2.1": + hashcls = Hash_SHA256 + elif oid == "2.16.840.1.101.3.4.2.2": + hashcls = Hash_SHA384 + elif oid == "2.16.840.1.101.3.4.2.3": + hashcls = Hash_SHA512 + else: + raise ValueError("Bad PAChecksum2 checksum !") + + if hashcls().digest(text) != self.checksum.val: + raise ValueError("Bad PAChecksum2 checksum !") + + def make(self, text, h="sha256"): + """ + Make a checksum. + + :param text: the bytes to make a checksum of + """ + # Only some OIDs are supported. Dumb but readable code. + if h == "sha1": + hashcls = Hash_SHA + self.algorithmIdentifier.algorithm = ASN1_OID("1.3.14.3.2.26") + elif h == "sha256": + hashcls = Hash_SHA256 + self.algorithmIdentifier.algorithm = ASN1_OID("2.16.840.1.101.3.4.2.1") + elif h == "sha384": + hashcls = Hash_SHA384 + self.algorithmIdentifier.algorithm = ASN1_OID("2.16.840.1.101.3.4.2.2") + elif h == "sha512": + hashcls = Hash_SHA512 + self.algorithmIdentifier.algorithm = ASN1_OID("2.16.840.1.101.3.4.2.3") + else: + raise ValueError("Bad PAChecksum2 checksum !") + + self.checksum = ASN1_STRING(hashcls().digest(text)) + # still RFC 4556 sect 3.2.1 -class PKAuthenticator(ASN1_Packet): +class KRB_PKAuthenticator(ASN1_Packet): ASN1_codec = ASN1_Codecs.BER ASN1_root = ASN1F_SEQUENCE( Microseconds("cusec", 0, explicit_tag=0xA0), @@ -1296,14 +1372,34 @@ class PKAuthenticator(ASN1_Packet): ), # RFC8070 extension ASN1F_optional( - ASN1F_STRING("freshnessToken", "", explicit_tag=0xA4), + ASN1F_STRING("freshnessToken", None, explicit_tag=0xA4), ), # [MS-PKCA] sect 2.2.3 ASN1F_optional( - ASN1F_PACKET("paChecksum2", None, PAChecksum2, explicit_tag=0xA5), + ASN1F_PACKET("paChecksum2", PAChecksum2(), PAChecksum2, explicit_tag=0xA5), ), ) + def make_checksum(self, text, h="sha256"): + """ + Populate paChecksum and paChecksum2 + """ + # paChecksum (always sha-1) + self.paChecksum = ASN1_STRING(Hash_SHA().digest(text)) + + # paChecksum2 + self.paChecksum2 = PAChecksum2() + self.paChecksum2.make(text, h=h) + + def verify_checksum(self, text): + """ + Verifiy paChecksum and paChecksum2 + """ + if self.paChecksum.val != Hash_SHA().digest(text): + raise ValueError("Bad paChecksum checksum !") + + self.paChecksum2.verify(text) + # RFC8636 sect 6 @@ -1318,13 +1414,13 @@ class KDFAlgorithmId(ASN1_Packet): # still RFC 4556 sect 3.2.1 -class AuthPack(ASN1_Packet): +class KRB_AuthPack(ASN1_Packet): ASN1_codec = ASN1_Codecs.BER ASN1_root = ASN1F_SEQUENCE( ASN1F_PACKET( "pkAuthenticator", - PKAuthenticator(), - PKAuthenticator, + KRB_PKAuthenticator(), + KRB_PKAuthenticator, explicit_tag=0xA0, ), ASN1F_optional( @@ -1344,7 +1440,7 @@ class AuthPack(ASN1_Packet): ), ), ASN1F_optional( - ASN1F_STRING("clientDCNonce", None, explicit_tag=0xA3), + ASN1F_STRING("clientDHNonce", None, explicit_tag=0xA3), ), # RFC8636 extension ASN1F_optional( @@ -1353,7 +1449,7 @@ class AuthPack(ASN1_Packet): ) -_CMS_ENCAPSULATED["1.3.6.1.5.2.3.1"] = AuthPack +_CMS_ENCAPSULATED["1.3.6.1.5.2.3.1"] = KRB_AuthPack # sect 3.2.3 @@ -2868,6 +2964,11 @@ def select(sockets, remain=None): # Util functions +class PKINIT_KEX_METHOD(IntEnum): + DIFFIE_HELLMAN = 1 + PUBLIC_KEY = 2 + + class KerberosClient(Automaton): """ Implementation of a Kerberos client. @@ -2901,8 +3002,12 @@ class KerberosClient(Automaton): :param x509: a X509 certificate to use for PKINIT AS_REQ or S4U2Proxy :param x509key: the private key of the X509 certificate (in an AS_REQ) + :param ca: the CA list that verifies the peer (KDC) certificate. Typically + only the ROOT CA is required. :param p12: (optional) use a pfx/p12 instead of x509 and x509key. In this case, 'password' is the password of the p12. + :param pkinit_kex_method: (advanced) whether to use the DIFFIE-HELLMAN method or the + Certificate based one for PKINIT. TGS-REQ only: @@ -2927,33 +3032,35 @@ class MODE(IntEnum): def __init__( self, mode=MODE.AS_REQ, - ip=None, - upn=None, - password=None, - key=None, - realm=None, - x509=None, - x509key=None, - p12=None, - spn=None, - ticket=None, - host=None, - renew=False, - additional_tickets=[], - u2u=False, - for_user=None, - s4u2proxy=False, - dmsa=False, - kdc_proxy=None, - kdc_proxy_no_check_certificate=False, - fast=False, - armor_ticket=None, - armor_ticket_upn=None, - armor_ticket_skey=None, - key_list_req=[], - etypes=None, - port=88, - timeout=5, + ip: Optional[str] = None, + upn: Optional[str] = None, + password: Optional[str] = None, + key: Optional[Key] = None, + realm: Optional[str] = None, + x509: Optional[Union[Cert, str]] = None, + x509key: Optional[Union[PrivKey, str]] = None, + ca: Optional[Union[CertTree, str]] = None, + p12: Optional[str] = None, + spn: Optional[str] = None, + ticket: Optional[KRB_Ticket] = None, + host: Optional[str] = None, + renew: bool = False, + additional_tickets: List[KRB_Ticket] = [], + u2u: bool = False, + for_user: Optional[str] = None, + s4u2proxy: bool = False, + dmsa: bool = False, + kdc_proxy: Optional[str] = None, + kdc_proxy_no_check_certificate: bool = False, + fast: bool = False, + armor_ticket: KRB_Ticket = None, + armor_ticket_upn: Optional[str] = None, + armor_ticket_skey: Optional[Key] = None, + key_list_req: List[EncryptionType] = [], + etypes: Optional[List[EncryptionType]] = None, + pkinit_kex_method: PKINIT_KEX_METHOD = PKINIT_KEX_METHOD.DIFFIE_HELLMAN, + port: int = 88, + timeout: int = 5, **kwargs, ): import scapy.libs.rfc3961 # Trigger error if any # noqa: F401 @@ -2977,29 +3084,50 @@ def __init__( # PKINIT checks if p12 is not None: - from cryptography.hazmat.primitives.serialization import pkcs12 - # password should be None or bytes if isinstance(password, str): password = password.encode() - # Read p12/pfx - with open(p12, "rb") as fd: - x509key, x509, _ = pkcs12.load_key_and_certificates( - fd.read(), - password=password, - ) - x509 = Cert(cryptography_obj=x509) - x509key = PrivKey(cryptography_obj=x509key) + # Read p12/pfx. If it fails and no password was provided, prompt and + # retry once. + while True: + try: + with open(p12, "rb") as fd: + x509key, x509, _ = pkcs12.load_key_and_certificates( + fd.read(), + password=password, + ) + break + except ValueError as ex: + if password is None: + # We don't have a password. Prompt and retry. + try: + from prompt_toolkit import prompt + + password = prompt( + "Enter PKCS12 password: ", is_password=True + ) + except ImportError: + password = input("Enter PKCS12 password: ") + password = password.encode() + else: + raise ex + + x509 = Cert(cryptography_obj=x509) + x509key = PrivKey(cryptography_obj=x509key) elif x509 and x509key: - x509 = Cert(x509) - x509key = PrivKey(x509key) + if not isinstance(x509, Cert): + x509 = Cert(x509) + if not isinstance(x509key, PrivKey): + x509key = PrivKey(x509key) + if not isinstance(ca, CertList): + ca = CertList(ca) if mode in [self.MODE.AS_REQ, self.MODE.GET_SALT]: if not host: raise ValueError("Invalid host") - if (x509 is None) ^ (x509key is None): - raise ValueError("Must provide both 'x509' and 'x509key' !") + if x509 is None and (not x509key or not ca): + raise ValueError("Must provide both 'x509', 'x509key' and 'ca' !") elif mode == self.MODE.TGS_REQ: if not ticket: raise ValueError("Invalid ticket") @@ -3035,15 +3163,11 @@ def __init__( if etypes is not None: raise ValueError("Cannot specify etypes in GET_SALT mode !") - from scapy.libs.rfc3961 import EncryptionType - etypes = [ EncryptionType.AES256_CTS_HMAC_SHA1_96, EncryptionType.AES128_CTS_HMAC_SHA1_96, ] elif etypes is None: - from scapy.libs.rfc3961 import EncryptionType - etypes = [ EncryptionType.AES256_CTS_HMAC_SHA1_96, EncryptionType.AES128_CTS_HMAC_SHA1_96, @@ -3071,6 +3195,7 @@ def __init__( self.realm = realm.upper() self.x509 = x509 self.x509key = x509key + self.pkinit_kex_method = pkinit_kex_method self.ticket = ticket self.fast = fast self.armor_ticket = armor_ticket @@ -3098,6 +3223,11 @@ def __init__( self.fast_skey = None # The random subkey used for fast self.fast_armorkey = None # The armor key self.fxcookie = None + self.pkinit_dh_key = None + if ca is not None: + self.pkinit_cms = CMS_Engine(ca) + else: + self.pkinit_cms = None sock = self._connect() super(KerberosClient, self).__init__( @@ -3155,8 +3285,6 @@ def calc_fast_armorkey(self): Calculate and return the FAST armorkey """ # Generate a random key of the same type than ticket_skey - from scapy.libs.rfc3961 import Key, KRB_FX_CF2 - if self.mode == self.MODE.AS_REQ: # AS-REQ mode self.fast_skey = Key.new_random_key(self.armor_ticket_skey.etype) @@ -3328,20 +3456,101 @@ def as_req(self): if self.pre_auth: if self.x509: # Special PKINIT (RFC4556) factor + + # RFC4556 - 3.2.1. Generation of Client Request + + # RFC4556 - 3.2.1 - (5) AuthPack + authpack = KRB_AuthPack( + pkAuthenticator=KRB_PKAuthenticator( + ctime=ASN1_GENERALIZED_TIME(now_time), + cusec=ASN1_INTEGER(0), + nonce=ASN1_INTEGER(RandNum(0, 0x7FFFFFFF)._fix()), + ), + clientPublicValue=None, # Used only in DH mode + supportedCMSTypes=None, + clientDHNonce=None, + supportedKDFs=None, + ) + + if self.pkinit_kex_method == PKINIT_KEX_METHOD.DIFFIE_HELLMAN: + # RFC4556 - 3.2.3.1. Diffie-Hellman Key Exchange + + # We use modp2048 + dh_parameters = _ffdh_groups["modp2048"][0] + self.pkinit_dh_key = dh_parameters.generate_private_key() + numbers = dh_parameters.parameter_numbers() + + # We can't use 'public_bytes' because it's the PKCS#3 format, + # and we want the DomainParameters format. + authpack.clientPublicValue = X509_SubjectPublicKeyInfo( + signatureAlgorithm=X509_AlgorithmIdentifier( + algorithm=ASN1_OID("dhpublicnumber"), + parameters=DomainParameters( + p=ASN1_INTEGER(numbers.p), + g=ASN1_INTEGER(numbers.g), + # q: see ERRATA 1 of RFC4556 + q=ASN1_INTEGER(numbers.q or (numbers.p - 1) // 2), + ), + ), + subjectPublicKey=DHPublicKey( + y=ASN1_INTEGER( + self.pkinit_dh_key.public_key().public_numbers().y + ), + ), + ) + elif self.pkinit_kex_method == PKINIT_KEX_METHOD.PUBLIC_KEY: + # RFC4556 - 3.2.3.2. - Public Key Encryption + + # Set supportedCMSTypes, supportedKDFs + authpack.supportedCMSTypes = [ + X509_AlgorithmIdentifier(algorithm=ASN1_OID(x)) + for x in [ + "ecdsa-with-SHA512", + "ecdsa-with-SHA256", + "sha512WithRSAEncryption", + "sha256WithRSAEncryption", + ] + ] + authpack.supportedKDFs = [ + KDFAlgorithmId(kdfId=ASN1_OID(x)) + for x in [ + "id-pkinit-kdf-sha256", + "id-pkinit-kdf-sha1", + "id-pkinit-kdf-sha512", + ] + ] + + # XXX UNFINISHED + raise NotImplementedError + else: + raise ValueError + + # Populate paChecksum and PAChecksum2 + authpack.pkAuthenticator.make_checksum(bytes(kdc_req)) + + # Sign the AuthPack + signedAuthpack = self.pkinit_cms.sign( + authpack, + ASN1_OID("id-pkinit-authData"), + self.x509, + self.x509key, + ) + + # Build PA-DATA pafactor = PADATA( padataType=16, # PA-PK-AS-REQ padataValue=PA_PK_AS_REQ( - + signedAuthpack=signedAuthpack, + trustedCertifiers=None, + kdcPkId=None, ), ) - raise NotImplementedError("PKINIT isn't implemented yet !") else: # Key-based factor if self.fast: # Special FAST factor # RFC6113 sect 5.4.6 - from scapy.libs.rfc3961 import KRB_FX_CF2 # Calculate the 'challenge key' ts_key = KRB_FX_CF2( @@ -3421,8 +3630,6 @@ def tgs_req(self): # [MS-SFU] FOR-USER extension if self.for_user is not None: - from scapy.libs.rfc3961 import ChecksumType, EncryptionType - # [MS-SFU] note 4: # "Windows Vista, Windows Server 2008, Windows 7, and Windows Server # 2008 R2 send the PA-S4U-X509-USER padata type alone if the user's @@ -3614,31 +3821,84 @@ def SENT_AS_REQ(self): def SENT_TGS_REQ(self): pass - def _process_padatas_and_key(self, padatas): - from scapy.libs.rfc3961 import EncryptionType, Key, KRB_FX_CF2 + def _process_padatas_and_key(self, padatas, etype: EncryptionType = None): + """ + Process the PADATA, and generate missing keys if required. - etype = None + :param etype: (optional) If provided, the EncryptionType to use. + """ salt = b"" + + if etype is not None and etype not in self.etypes: + raise ValueError("The answered 'etype' key isn't supported by us !") + # 1. Process pa-data if padatas is not None: for padata in padatas: if padata.padataType == 0x13 and etype is None: # PA-ETYPE-INFO2 + # We obtain the salt for hash types that need it elt = padata.padataValue.seq[0] if elt.etype.val in self.etypes: etype = elt.etype.val if etype != EncryptionType.RC4_HMAC: salt = elt.salt.val + + elif padata.padataType == 0x11: # PA-PK-AS-REP + # PKINIT handling + + # The steps are as follows: + # 1. Verify and extract the CMS response. The expected type + # is different depending on the used method. + # 2. Compute the replykey + + if self.pkinit_kex_method == PKINIT_KEX_METHOD.DIFFIE_HELLMAN: + # Unpack KDCDHKeyInfo + keyinfo = self.pkinit_cms.verify( + padata.padataValue.rep.dhSignedData, + eContentType=ASN1_OID("id-pkinit-DHKeyData"), + ) + + # If 'etype' is None, we're in an error. Since we verified + # the CMS successfully, end here. + if etype is None: + continue + + # Extract crypto parameters + y = keyinfo.subjectPublicKey.y.val + + # Import into cryptography + params = self.pkinit_dh_key.parameters().parameter_numbers() + pubkey = dh.DHPublicNumbers(y, params).public_key() + + # Calculate DHSharedSecret + DHSharedSecret = self.pkinit_dh_key.exchange(pubkey) + + # RFC4556 3.2.3.1 - AS reply key is derived as follows + self.replykey = octetstring2key( + etype, + DHSharedSecret, + ) + + else: + raise ValueError + elif padata.padataType == 133: # PA-FX-COOKIE + # Get cookie and store it self.fxcookie = padata.padataValue + elif padata.padataType == 136: # PA-FX-FAST + # FAST handling: get the actual inner message and decrypt it if isinstance(padata.padataValue, PA_FX_FAST_REPLY): self.fast_rep = ( padata.padataValue.armoredData.encFastRep.decrypt( self.fast_armorkey, ) ) + elif padata.padataType == 137: # PA-FX-ERROR + # Get error and store it self.fast_error = padata.padataValue + elif padata.padataType == 130: # PA-FOR-X509-USER # Verify S4U checksum key_usage_number = None @@ -3653,17 +3913,17 @@ def _process_padatas_and_key(self, padatas): key_usage_number=key_usage_number, ) - # 2. Update the current key if necessary + # 2. Update the current keys if necessary - # Compute key if not already provided - if self.key is None and etype is not None: + # Compute client key if not already provided + if self.key is None and etype is not None and self.x509 is None: self.key = Key.string_to_key( etype, self.password, salt, ) - # Update the key with the fast reply, if necessary + # Strengthen the reply key with the fast reply, if necessary if self.fast_rep and self.fast_rep.strengthenKey: # "The strengthen-key field MAY be set in an AS reply" self.replykey = KRB_FX_CF2( @@ -3718,7 +3978,7 @@ def receive_krb_error_as_req(self, pkt): return if pkt.root.errorCode == 25: # KDC_ERR_PREAUTH_REQUIRED - if not self.key: + if not self.key and not self.x509: log_runtime.error( "Got 'KDC_ERR_PREAUTH_REQUIRED', " "but no possible key could be computed." @@ -3750,7 +4010,12 @@ def retry_after_eof_in_apreq(self): @ATMT.action(receive_as_rep) def decrypt_as_rep(self, pkt): - self._process_padatas_and_key(pkt.root.padata) + # Process PADATAs. This is important for FAST and PKINIT + self._process_padatas_and_key( + pkt.root.padata, + etype=pkt.root.encPart.etype.val, + ) + if not self.pre_auth: log_runtime.warning("Pre-authentication was disabled for this account !") @@ -3765,6 +4030,10 @@ def decrypt_as_rep(self, pkt): elif self.fast: raise ValueError("Answer was not FAST ! Is it supported?") + # Check for PKINIT + if self.x509 and self.replykey is None: + raise ValueError("PKINIT was used but no valid PA-PK-AS-REP was found !") + # Decrypt AS-REP response enc = pkt.root.encPart res = enc.decrypt(self.replykey) @@ -3892,16 +4161,16 @@ def _spn_are_equal(spn1, spn2): def krb_as_req( - upn, - spn=None, - ip=None, - key=None, - password=None, - realm=None, - host="WIN10", - p12=None, - x509=None, - x509key=None, + upn: str, + spn: Optional[str] = None, + ip: Optional[str] = None, + key: Optional[Key] = None, + password: Optional[str] = None, + realm: Optional[str] = None, + host: str = "WIN10", + p12: Optional[str] = None, + x509: Optional[Union[str, Cert]] = None, + x509key: Optional[Union[str, PrivKey]] = None, **kwargs, ): r""" @@ -3916,7 +4185,7 @@ def krb_as_req( :param key: (optional) pass the Key object. :param password: (optional) otherwise, pass the user's password :param x509: (optional) pass a x509 certificate for PKINIT. - :param x509key: (optional) pass the key of the x509 certificate for PKINIT. + :param x509key: (optional) pass the private key of the x509 certificate for PKINIT. :param p12: (optional) use a pfx/p12 instead of x509 and x509key. In this case, 'password' is the password of the p12. :param realm: (optional) the realm to use. Otherwise use the one from UPN. @@ -4364,6 +4633,8 @@ def __init__( debug=0, **kwargs, ): + import scapy.libs.rfc3961 # Trigger error if any # noqa: F401 + self.ST = ST self.UPN = UPN self.KEY = KEY @@ -4374,8 +4645,6 @@ def __init__( self.DC_IP = DC_IP self.debug = debug if SKEY_TYPE is None: - from scapy.libs.rfc3961 import EncryptionType - SKEY_TYPE = EncryptionType.AES128_CTS_HMAC_SHA1_96 self.SKEY_TYPE = SKEY_TYPE super(KerberosSSP, self).__init__(**kwargs) @@ -4527,13 +4796,6 @@ def GSS_WrapEx(self, Context, msgs, qop_req=0): tok.root.Data = strrot(Data, tok.root.RRC) return msgs, tok elif Context.KrbSessionKey.etype in [23, 24]: # RC4 - from scapy.libs.rfc3961 import ( - Cipher, - Hmac_MD5, - _rfc1964pad, - decrepit_algorithms, - ) - # Build token seq = struct.pack(">I", Context.SendSeqNum) tok = KRB_InnerToken( @@ -4697,13 +4959,6 @@ def MakeToSign(Confounder, DecText): msgs[0].data = Data return msgs elif Context.KrbSessionKey.etype in [23, 24]: # RC4 - from scapy.libs.rfc3961 import ( - Cipher, - Hmac_MD5, - _rfc1964pad, - decrepit_algorithms, - ) - # Drop wrapping tok = signature.innerToken @@ -4770,8 +5025,6 @@ def GSS_Init_sec_context( # New context Context = self.CONTEXT(IsAcceptor=False, req_flags=req_flags) - from scapy.libs.rfc3961 import Key - if Context.state == self.STATE.INIT and self.U2U: # U2U - Get TGT Context.state = self.STATE.CLI_SENT_TGTREQ @@ -4909,7 +5162,9 @@ def GSS_Init_sec_context( adData=KERB_AD_RESTRICTION_ENTRY( restriction=LSAP_TOKEN_INFO_INTEGRITY( MachineID=bytes(RandBin(32)), - PermanentMachineID=bytes(RandBin(32)), # noqa: E501 + PermanentMachineID=bytes( + RandBin(32) + ), ) ), ), @@ -5018,7 +5273,6 @@ def GSS_Accept_sec_context( # New context Context = self.CONTEXT(IsAcceptor=True, req_flags=req_flags) - from scapy.libs.rfc3961 import Key import scapy.layers.msrpce.mspac # noqa: F401 if Context.state == self.STATE.INIT: diff --git a/scapy/layers/smbclient.py b/scapy/layers/smbclient.py index 360acbce824..2239bc792c1 100644 --- a/scapy/layers/smbclient.py +++ b/scapy/layers/smbclient.py @@ -1123,7 +1123,7 @@ def __init__( HashAes256Sha96: bytes = None, HashAes128Sha96: bytes = None, port: int = 445, - timeout: int = 2, + timeout: int = 5, debug: int = 0, ssp=None, ST=None, diff --git a/scapy/layers/spnego.py b/scapy/layers/spnego.py index 3afb73268ed..b9959078f14 100644 --- a/scapy/layers/spnego.py +++ b/scapy/layers/spnego.py @@ -641,6 +641,7 @@ def from_cli_arguments( kerberos_required: bool = False, ST=None, KEY=None, + ccache: str = None, debug: int = 0, ): """ @@ -657,6 +658,7 @@ def from_cli_arguments( :param ST: if provided, the service ticket to use (Kerberos) :param KEY: if ST provided, the session key associated to the ticket (Kerberos). Else, the user secret key. + :param ccache: (str) if provided, a path to a CCACHE (Kerberos) """ kerberos = True hostname = None @@ -700,7 +702,20 @@ def from_cli_arguments( # Kerberos if kerberos and hostname: # Get ticket if we don't already have one. - if ST is None: + if ST is None and ccache is not None: + # In this case, load the KerberosSSP from ccache + from scapy.modules.ticketer import Ticketer + + # Import into a Ticketer object + t = Ticketer() + t.open_ccache(ccache) + + # Look for the ticketer that we'll use + raise NotImplementedError + + + ssps.append(t.ssp()) + elif ST is None: # In this case, KEY is supposed to be the user's key. from scapy.libs.rfc3961 import Key, EncryptionType diff --git a/scapy/layers/tls/cert.py b/scapy/layers/tls/cert.py index 397c02c2e03..ed2df5a853d 100644 --- a/scapy/layers/tls/cert.py +++ b/scapy/layers/tls/cert.py @@ -4,36 +4,58 @@ # Copyright (C) 2008 Arnaud Ebalard # # 2015, 2016, 2017 Maxence Tury +# 2022-2025 Gabriel Potter """ High-level methods for PKI objects (X.509 certificates, CRLs, asymmetric keys, CMS). Supports both RSA, ECDSA and EDDSA objects. The classes below are wrappers for the ASN.1 objects defined in x509.py. + +Example 1: Certificate & Private key +____________________________________ + For instance, here is what you could do in order to modify the subject public key info of a 'cert' and then resign it with whatever 'key':: - from scapy.layers.tls.cert import * - cert = Cert("cert.der") - k = PrivKeyRSA() # generate a private key - cert.setSubjectPublicKeyFromPrivateKey(k) - cert.resignWith(k) - cert.export("newcert.pem") - k.export("mykey.pem") + >>> from scapy.layers.tls.cert import * + >>> cert = Cert("cert.der") + >>> k = PrivKeyRSA() # generate a private key + >>> cert.setSubjectPublicKeyFromPrivateKey(k) + >>> cert.resignWith(k) + >>> cert.export("newcert.pem") + >>> k.export("mykey.pem") One could also edit arguments like the serial number, as such:: - from scapy.layers.tls.cert import * - c = Cert("mycert.pem") - c.tbsCertificate.serialNumber = 0x4B1D - k = PrivKey("mykey.pem") # import an existing private key - c.resignWith(k) - c.export("newcert.pem") + >>> from scapy.layers.tls.cert import * + >>> c = Cert("mycert.pem") + >>> c.tbsCertificate.serialNumber = 0x4B1D + >>> k = PrivKey("mykey.pem") # import an existing private key + >>> c.resignWith(k) + >>> c.export("newcert.pem") To export the public key of a private key:: - k = PrivKey("mykey.pem") - k.pubkey.export("mypubkey.pem") + >>> k = PrivKey("mykey.pem") + >>> k.pubkey.export("mypubkey.pem") + +Example 2: CertList and CertTree +________________________________ + +Load a .pem file that contains multiple certificates:: + + >>> l = CertList("ca_chain.pem") + >>> l.show() + 0000 [X.509 Cert Subject:/C=FR/OU=Scapy Test PKI/CN=Scapy Test CA...] + 0001 [X.509 Cert Subject:/C=FR/OU=Scapy Test PKI/CN=Scapy Test Client...] + +Use 'CertTree' to organize the certificates in a tree:: + + >>> tree = CertTree("ca_chain.pem") # or tree = CertTree(l) + >>> tree.show() + /C=Ulaanbaatar/OU=Scapy Test PKI/CN=Scapy Test CA [Self Signed] + /C=FR/OU=Scapy Test PKI/CN=Scapy Test Client [Not Self Signed] No need for obnoxious openssl tweaking anymore. :) """ @@ -43,6 +65,7 @@ import time from scapy.config import conf, crypto_validator +from scapy.compat import Self from scapy.error import warning from scapy.utils import binrepr from scapy.asn1.asn1 import ( @@ -1003,6 +1026,12 @@ def pem(self): def der(self): return bytes(self.x509Cert) + def __eq__(self, other): + return self.der == other.der + + def __hash__(self): + return hash(self.der) + def export(self, filename, fmt=None): """ Export certificate in 'fmt' format (DER or PEM) to file 'filename' @@ -1134,45 +1163,29 @@ def show(self): print("nextUpdate: %s" % self.nextUpdate_str) -###################### -# Certificate chains # -###################### +#################### +# Certificate list # +#################### -class Chain(list): +class CertList(list): """ - An enhanced array of Cert. + An object that can store a list of Cert objects, load them and export them + into DER/PEM format. """ def __init__( self, - certList: Union[List[Cert], str], - cert0: Union[Cert, str, None] = None, + certList: Union[Self, List[Cert], Cert, str], ): """ - Construct a chain of certificates that follows issuer/subject matching and - respects signature validity. - - If there is exactly one chain to be constructed, it will be, - but if there are multiple potential chains, there is no guarantee - that the retained one will be the longest one. - As Cert and CRL classes both share an isIssuerCert() method, - the trailing element of a Chain may alternatively be a CRL. - - Note that we do not check AKID/{SKID/issuer/serial} matching, - nor the presence of keyCertSign in keyUsage extension (if present). - - :param certList: either a list of certificates, or a path to a file containing - a list of certificates. - :param cert0: if provided, force the ROOT CA of the chain. + Construct a list of certificates/CRLs to be used as list of ROOT certificates. """ - super(Chain, self).__init__(()) - # Parse the certificate list / CA if isinstance(certList, str): # It's a path. First get the _PKIObj - obj = _PKIObjMaker.__call__(Chain, certList, _MAX_CERT_SIZE, + obj = _PKIObjMaker.__call__(CertList, certList, _MAX_CERT_SIZE, "CERTIFICATE") - + # Then parse the der until there's nothing left certList = [] payload = obj._der @@ -1186,112 +1199,17 @@ def __init__( certList.append(Cert(cert)) self.frmt = obj.frmt - else: + elif isinstance(certList, Cert): + certList = [certList] self.frmt = "PEM" - - if isinstance(cert0, str): - cert0 = Cert(cert0) - - # Find the ROOT CA - if cert0: - self.append(cert0) else: - for root_candidate in certList: - if root_candidate.isSelfSigned(): - self.append(root_candidate) - certList.remove(root_candidate) - break - - # Build the chain - if self: - while certList: - tmp_len = len(self) - for c in certList: - if c.isIssuerCert(self[-1]): - self.append(c) - certList.remove(c) - break - if len(self) == tmp_len: - # no new certificate appended to self - break - - def verifyChain(self, anchors, untrusted=None): - """ - Perform verification of certificate chains for that certificate. - A list of anchors is required. The certificates in the optional - untrusted list may be used as additional elements to the final chain. - On par with chain instantiation, only one chain constructed with the - untrusted candidates will be retained. Eventually, dates are checked. - """ - untrusted = untrusted or [] - for a in anchors: - chain = Chain(self + untrusted, a) - if len(chain) == 1: # anchor only - continue - # check that the chain does not exclusively rely on untrusted - if any(c in chain[1:] for c in self): - for c in chain: - if c.remainingDays() < 0: - break - if c is chain[-1]: # we got to the end of the chain - return chain - return None - - def verifyChainFromCAFile(self, cafile, untrusted_file=None): - """ - Does the same job as .verifyChain() but using the list of anchors - from the cafile. As for .verifyChain(), a list of untrusted - certificates can be passed (as a file, this time). - """ - try: - with open(cafile, "rb") as f: - ca_certs = f.read() - except Exception: - raise Exception("Could not read from cafile") - - anchors = [Cert(c) for c in split_pem(ca_certs)] - - untrusted = None - if untrusted_file: - try: - with open(untrusted_file, "rb") as f: - untrusted_certs = f.read() - except Exception: - raise Exception("Could not read from untrusted_file") - untrusted = [Cert(c) for c in split_pem(untrusted_certs)] - - return self.verifyChain(anchors, untrusted) - - def verifyChainFromCAPath(self, capath, untrusted_file=None): - """ - Does the same job as .verifyChainFromCAFile() but using the list - of anchors in capath directory. The directory should (only) contain - certificates files in PEM format. As for .verifyChainFromCAFile(), - a list of untrusted certificates can be passed as a file - (concatenation of the certificates in PEM format). - """ - try: - anchors = [] - for cafile in os.listdir(capath): - with open(os.path.join(capath, cafile), "rb") as fd: - anchors.append(Cert(fd.read())) - except Exception: - raise Exception("capath provided is not a valid cert path") - - untrusted = None - if untrusted_file: - try: - with open(untrusted_file, "rb") as f: - untrusted_certs = f.read() - except Exception: - raise Exception("Could not read from untrusted_file") - untrusted = [Cert(c) for c in split_pem(untrusted_certs)] + self.frmt = "PEM" - return self.verifyChain(anchors, untrusted) + super(CertList, self).__init__(certList) def findCertByIssuer(self, issuer): """ - Find a certificate in the chain by issuer. + Find a certificate in the list by issuer. """ for cert in self: if cert.issuer == issuer: @@ -1300,7 +1218,7 @@ def findCertByIssuer(self, issuer): def export(self, filename, fmt=None): """ - Export a chain of certificates 'fmt' format (DER or PEM) to file 'filename' + Export a list of certificates 'fmt' format (DER or PEM) to file 'filename' """ if fmt is None: if filename.endswith(".pem"): @@ -1322,24 +1240,167 @@ def pem(self): return "".join(x.pem for x in self) def __repr__(self): - llen = len(self) - 1 - if llen < 0: - return "" - c = self[0] - s = "__ " - if not c.isSelfSigned(): - s += "%s [Not Self Signed]\n" % c.subject_str + return "" % ( + len(self), + ) + + def show(self): + for i, c in enumerate(self): + print(conf.color_theme.id(i, fmt="%04i"), end=' ') + print(repr(c)) + + +###################### +# Certificate chains # +###################### + +class CertTree(CertList): + """ + An extension to CertList that additionally has a list of ROOT CAs + that are trusted. + + Example:: + + >>> tree = CertTree("ca_chain.pem") + >>> tree.show() + /CN=DOMAIN-DC1-CA/dc=DOMAIN [Self Signed] + /CN=Administrator/dc=DOMAIN [Not Self Signed] + """ + + __slots__ = ["frmt", "rootCAs"] + + def __init__( + self, + certList: Union[List[Cert], CertList, str], + rootCAs: Union[List[Cert], CertList, Cert, str, None] = None, + ): + """ + Construct a chain of certificates that follows issuer/subject matching and + respects signature validity. + + Note that we do not check AKID/{SKID/issuer/serial} matching, + nor the presence of keyCertSign in keyUsage extension (if present). + + :param certList: a list of Cert/CRL objects (or path to PEM/DER file containing + multiple certs/CRL) to try to chain. + :param rootCAs: (optional) a list of certificates to trust. If not provided, + trusts any self-signed certificates from the certList. + """ + # Parse the certificate list + certList = CertList(certList) + + # Find the ROOT CAs if store isn't specified + if not rootCAs: + # Build cert store. + self.rootCAs = CertList([ + x + for x in certList + if x.isSelfSigned() + ]) + # And remove those certs from the list + for cert in self.rootCAs: + certList.remove(cert) else: - s += "%s [Self Signed]\n" % c.subject_str - idx = 1 - while idx <= llen: - c = self[idx] - s += "%s_ %s" % (" " * idx * 2, c.subject_str) - if idx != llen: - s += "\n" - idx += 1 - return s + self.rootCAs = CertList(rootCAs) + + # Append our root CAs to the certList + certList.extend(self.rootCAs) + # Super instantiate + super(CertTree, self).__init__(certList) + + @property + def tree(self): + """ + Get a tree-like object of the certificate list + """ + # We store the tree object as a dictionary that contains children. + tree = [ + (x, []) + for x in self.rootCAs + ] + + # We'll empty this list eventually + certList = list(self) + + # We make a list of certificates we have to search children for, and iterate + # through it until it's emtpy. + todo = list(tree) + + # Iterate + while todo: + cert, children = todo.pop() + for c in certList: + # Check if this certificate matches the one we're looking at + if c.isIssuerCert(cert) and c != cert: + item = (c, []) + children.append(item) + certList.remove(c) + todo.append(item) + + return tree + + def getchain(self, cert): + """ + Return a chain of certificate that points from a ROOT CA to a certificate. + """ + def _rec_getchain(chain, curtree): + # See if an element of the current tree signs the cert, if so add it to + # the chain, else recurse. + for c, subtree in curtree: + curchain = chain + [c] + if cert.isIssuerCert(c): + return curchain + else: + curchain = _rec_getchain(curchain, subtree) + if curchain: + return curchain + return None + + chain = _rec_getchain([], self.tree) + if chain is not None: + return CertTree(cert, chain) + else: + return None + + def verify(self, cert): + """ + Verify that a certificate is properly signed. + """ + # Check that we can find a chain to this certificate + if not self.getchain(cert): + raise ValueError("Certificate verification failed !") + + def show(self, ret: bool = False): + """ + Return the CertTree as a string certificate tree + """ + def _rec_show(c, children, lvl=0): + s = "" + # Process the current CA + if c: + if not c.isSelfSigned(): + s += "%s [Not Self Signed]\n" % c.subject_str + else: + s += "%s [Self Signed]\n" % c.subject_str + s = lvl * " " + s + lvl += 1 + # Process all sub-CAs at a lower level + for child, subchildren in children: + s += _rec_show(child, subchildren, lvl=lvl) + return s + + showed = _rec_show(None, self.tree) + if ret: + return showed + else: + print(showed) + + def __repr__(self): + return "" % ( + len(self), + len(self.rootCAs), + ) ####### # CMS # @@ -1352,16 +1413,16 @@ class CMS_Engine: """ A utility class to perform CMS/PKCS7 operations, as specified by RFC3852. - :param chain: a certificates chain to sign or validate messages against. + :param store: a ROOT CA certificate list to trust. :param crls: a list of CRLs to include. This is currently not checked. """ def __init__( self, - chain: Chain, + store: CertList, crls: List[X509_CRL] = [], ): - self.chain = chain + self.store = store self.crls = crls def sign( @@ -1383,12 +1444,6 @@ def sign( We currently only support X.509 certificates ! """ - # RFC3852 sect 5.1 - SignedData Type version - if self.chain: - version = 3 - else: - version = 1 - # RFC3852 - 5.4. Message Digest Calculation Process h = h or cert.getSignatureHashName() hash = hashes.Hash(_get_hash(h)) @@ -1434,20 +1489,19 @@ def sign( ) ) - # Build a list of X509_Cert to ship (no ROOT certificate) + # Build a chain of X509_Cert to ship (but skip the ROOT certificate) + certTree = CertTree(cert, self.store) certificates = [ - x for x in - self.chain + x.x509Cert + for x in certTree if not x.isSelfSigned() ] - if cert.x509Cert not in certificates: - certificates.append(cert.x509Cert) # Build final structure return CMS_ContentInfo( contentType=ASN1_OID("id-signedData"), content=CMS_SignedData( - version=version, + version=3 if certificates else 1, digestAlgorithms=X509_AlgorithmIdentifier( algorithm=ASN1_OID(h), parameters=ASN1_NULL(0), @@ -1500,7 +1554,7 @@ def verify( Cert(x.certificate) for x in signeddata.certificates ] - chain = Chain(self.chain + certificates) + certTree = CertTree(certificates, self.store) # Check there's at least one signature if not signeddata.signerInfos: @@ -1509,7 +1563,10 @@ def verify( # Check all signatures for signerInfo in signeddata.signerInfos: # Find certificate in the chain that did this - cert: Cert = chain.findCertByIssuer(signerInfo.sid.get_issuer()) + cert: Cert = certTree.findCertByIssuer(signerInfo.sid.get_issuer()) + + # Verify certificate signature + certTree.verify(cert) # Verify the message hash if signerInfo.signedAttrs: diff --git a/scapy/layers/x509.py b/scapy/layers/x509.py index cf031cc70c4..5ca30babd0a 100644 --- a/scapy/layers/x509.py +++ b/scapy/layers/x509.py @@ -136,8 +136,7 @@ class DomainParameters(ASN1_Packet): ASN1_root = ASN1F_SEQUENCE( ASN1F_INTEGER("p", 0), ASN1F_INTEGER("g", 0), - # BUG: 'q' isn't supposed to be optional, yet Windows skipts it sometimes... - ASN1F_optional(ASN1F_INTEGER("q", 0)), + ASN1F_INTEGER("q", 0), ASN1F_optional(ASN1F_INTEGER("j", 0)), ASN1F_optional( ASN1F_PACKET("validationParms", None, ValidationParms), @@ -219,6 +218,24 @@ class ECDSASignature(ASN1_Packet): ASN1F_INTEGER("s", 0)) +#################################### +# Diffie Hellman Exchange Packets # +#################################### +# based on PKCS#3 + +# PKCS#3 sect 9 + +class DHParameter(ASN1_Packet): + ASN1_codec = ASN1_Codecs.BER + ASN1_root = ASN1F_SEQUENCE( + ASN1F_INTEGER("p", 0), + ASN1F_INTEGER("g", 0), + ASN1F_optional( + ASN1F_INTEGER("l", 0) # aka. 'privateValueLength' + ), + ) + + #################################### # x25519/x448 packets # #################################### @@ -848,13 +865,37 @@ class X509_AlgorithmIdentifier(ASN1_Packet): ASN1_codec = ASN1_Codecs.BER ASN1_root = ASN1F_SEQUENCE( ASN1F_OID("algorithm", "1.2.840.113549.1.1.11"), - ASN1F_optional( - ASN1F_CHOICE( - "parameters", ASN1_NULL(0), - ASN1F_NULL, - ECParameters, - DomainParameters, - ) + MultipleTypeField( + [ + # RFC5480 + ( + ASN1F_PACKET( + "parameters", + ECParameters(), + ECParameters, + ), + lambda pkt: pkt.algorithm.val == "1.2.840.10045.2.1", + ), + # RFC3279 + ( + ASN1F_PACKET( + "parameters", + DomainParameters(), + DomainParameters, + ), + lambda pkt: pkt.algorithm.val == "1.2.840.10046.2.1", + ), + # PKCS#3 + ( + ASN1F_PACKET( + "parameters", + DHParameter(), + DHParameter, + ), + lambda pkt: pkt.algorithm.val == "1.2.840.113549.1.3.1", + ), + ], + ASN1F_optional(ASN1F_NULL("parameters", None)), ) ) @@ -1192,11 +1233,6 @@ class CMS_EncapsulatedContentInfo(ASN1_Packet): _EncapsulatedContent_Field("eContent", None, explicit_tag=0xA0), ), - # BUG: some Windows versions incorrectly use an implicit octet string. - ASN1F_optional( - _EncapsulatedContent_Field("_eContent", None, - implicit_tag=0xA0), - ) ) diff --git a/scapy/libs/rfc3961.py b/scapy/libs/rfc3961.py index ed6581ceaff..bc7e2e8aee5 100644 --- a/scapy/libs/rfc3961.py +++ b/scapy/libs/rfc3961.py @@ -1445,3 +1445,35 @@ def prfplus(key, pepper): ) ), ) + + +############ +# RFC 4556 # +############ + +def octetstring2key(etype: EncryptionType, x: bytes) -> bytes: + """ + RFC4556 octetstring2key:: + + octetstring2key(x) == random-to-key(K-truncate( + SHA1(0x00 | x) | + SHA1(0x01 | x) | + SHA1(0x02 | x) | + ... + )) + """ + try: + ep = _enctypes[etype] + except ValueError: + raise ValueError("Unknown etype '%s'" % etype) + + out = b"" + count = 0 + while len(out) < ep.keysize: + out += Hash_SHA().digest(struct.pack("!B", count) + x) + count += 1 + + return Key.random_to_key( + etype=etype, + seed=out[:ep.keysize], + ) diff --git a/scapy/modules/ticketer.py b/scapy/modules/ticketer.py index 87c591753bc..78f1c7e234d 100644 --- a/scapy/modules/ticketer.py +++ b/scapy/modules/ticketer.py @@ -2424,6 +2424,9 @@ def request_tgt( fast=False, armor_with=None, spn=None, + x509=None, + x509key=None, + p12=None, **kwargs, ): """ @@ -2458,6 +2461,9 @@ def request_tgt( armor_ticket_upn=armor_ticket_upn, armor_ticket_skey=armor_ticket_skey, spn=spn, + x509=x509, + x509key=x509key, + p12=p12, **kwargs, ) if not res: diff --git a/test/configs/cryptography.utsc b/test/configs/cryptography.utsc index 53b307d2897..b5267234bbf 100644 --- a/test/configs/cryptography.utsc +++ b/test/configs/cryptography.utsc @@ -15,7 +15,6 @@ "test/scapy/layers/tls/*.uts": "load_layer(\"tls\")" }, "kw_ko": [ - "mock", "needs_root" ] } diff --git a/test/scapy/layers/kerberos.uts b/test/scapy/layers/kerberos.uts index 36224702ecd..b8a44f28169 100644 --- a/test/scapy/layers/kerberos.uts +++ b/test/scapy/layers/kerberos.uts @@ -217,17 +217,17 @@ assert authpack.pkAuthenticator.paChecksum2.algorithmIdentifier.algorithm.oidnam = PKINIT - Verify CMS signature and extract -from scapy.layers.tls.cert import Cert, PrivKey, Chain, CMS_Engine +from scapy.layers.tls.cert import Cert, PrivKey, CertList, CMS_Engine # Get root CA ca = Cert(bytes.fromhex('3082036930820251a00302010202106b671318bb858b8e437e4229b0d32f12300d06092a864886f70d01010b0500304731153013060a0992268993f22c64011916054c4f43414c31163014060a0992268993f22c6401191606444f4d41494e311630140603550403130d444f4d41494e2d4443312d4341301e170d3235303932303232313034365a170d3330303932303232323034365a304731153013060a0992268993f22c64011916054c4f43414c31163014060a0992268993f22c6401191606444f4d41494e311630140603550403130d444f4d41494e2d4443312d434130820122300d06092a864886f70d01010105000382010f003082010a0282010100d502f47f909c951c87f2e8e6ac1c6f86d555b3311e5ef6086b588fb5eeb66277f63d18f04e65ba07570999bcc7cca3e0fa70914fcfa8acd81d4fbf4bb570a089b1b897cf3e07abc9fa75417bcb7171aaa95e20df12add93fada7df5447210820c1de12e356b248b7fe169019b7cf254c5be50571da26ff4219b8680fa249c14673bf743ef37b46c740353cb88097a099fbc7ca41a79c2cd9bc3a663003edfd12678c88b3970fdc211e38b985d6795d57041de0f3182873670bfee903069f59d3f0ff1634bf57f122ef7d1511775c47fdc574f632c9a1e8af305c81077af542f5499977870d8b0bce0d1fd8088636814d7847e0863ceb0ebe8bb0bd4e47eed01d0203010001a351304f300b0603551d0f040403020186300f0603551d130101ff040530030101ff301d0603551d0e04160414ab14d5ae948281f079726970b3b8f97003aa760c301006092b06010401823715010403020100300d06092a864886f70d01010b05000382010100763c9c93d6f0dd98d6ee5269f1d5f8b83fa14e62a9513806f6f978769208ff65f263f1809743f42b6b70cc77f93f5278e62e4d1da2ae5285e8da155951aa5207cea519d373a202d889e37a9fdde6c79e7a574d2dacd3ea695fde5980d16f91b14cd8f3944cc6a5d3d4c5d95e12f863857fe733285ac04d43fdb0ee52dc8ae5c8d1dd6e32405df2f835bd1681dbf5af9fc523cfe31c31fcde16a07f90733f48cff0392a0a18a1787b91d6b67441d78f507043acfb99c64eebc77717a21cf85ec160411a8f8244f8ef493ad22e5bbdb73d647fc6d911b040d373740b11fa65df5f2a8087ae63f69da5fc14e2e320f6d3e013d319a15762ec6ee2eb3cdf9763a523')) # Build CMS engine to verify the authpack -cms = CMS_Engine(Chain([ca])) +cms = CMS_Engine(CertList([ca])) # Verify signature authpack = cms.verify(signedauthpack, ASN1_OID('id-pkinit-authData')) -assert isinstance(authpack, AuthPack) +assert isinstance(authpack, KRB_AuthPack) = PKINIT - Resign AuthPack and re-verify signature @@ -244,7 +244,7 @@ signed = cms.sign( ) authpack = cms.verify(signed, ASN1_OID('id-pkinit-authData')) -assert isinstance(authpack, AuthPack) +assert isinstance(authpack, KRB_AuthPack) = PKINIT - Parse AS-REP with CMS structures (MIT Kerberos) @@ -1389,6 +1389,110 @@ _msgs = ssp.GSS_UnwrapEx( assert _msgs[0].data.hex() == "112233445566778899aabbccddeeff" ++ RFC4556 test vectors +~ mock + += RFC4556 Test Vectors - octetstring2key - Utils + +from scapy.libs.rfc3961 import EncryptionType, octetstring2key + +def _strip(x): + return bytes.fromhex(x.replace(" ", "").replace("\n", "")) + +def _k_truncate_output(etype, input): + with mock.patch('scapy.libs.rfc3961.Key.random_to_key', side_effect=Bunch): + result = octetstring2key(EncryptionType.AES256_CTS_HMAC_SHA1_96, INPUT) + return result.seed + += RFC4556 Test Vectors - octetstring2key - Set 1 + +INPUT = _strip(""" +00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 +00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 +00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 +00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 +00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 +00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 +00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 +00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 +00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 +00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 +00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 +00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 +00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 +00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 +00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 +00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 +""") + +RESULT = _strip(""" +5e e5 0d 67 5c 80 9f e5 9e 4a 77 62 c5 4b 65 83 +75 47 ea fb 15 9b d8 cd c7 5f fc a5 91 1e 4c 41 +""") + +hexdiff(_k_truncate_output(EncryptionType.AES256_CTS_HMAC_SHA1_96, INPUT), RESULT) +assert _k_truncate_output(EncryptionType.AES256_CTS_HMAC_SHA1_96, INPUT) == RESULT + += RFC4556 Test Vectors - octetstring2key - Set 2 + +INPUT = _strip(""" +00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 +00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 +00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 +00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 +00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 +00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 +00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 +00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 +""") + +RESULT = _strip(""" +ac f7 70 7c 08 97 3d df db 27 cd 36 14 42 cc fb +a3 55 c8 88 4c b4 72 f3 7d a6 36 d0 7d 56 78 7e +""") + +hexdiff(_k_truncate_output(EncryptionType.AES256_CTS_HMAC_SHA1_96, INPUT), RESULT) +assert _k_truncate_output(EncryptionType.AES256_CTS_HMAC_SHA1_96, INPUT) == RESULT + += RFC4556 Test Vectors - octetstring2key - Set 3 + +INPUT = _strip(""" +00 01 02 03 04 05 06 07 08 09 0a 0b 0c 0d 0e 0f +10 00 01 02 03 04 05 06 07 08 09 0a 0b 0c 0d 0e +0f 10 00 01 02 03 04 05 06 07 08 09 0a 0b 0c 0d +0e 0f 10 00 01 02 03 04 05 06 07 08 09 0a 0b 0c +0d 0e 0f 10 00 01 02 03 04 05 06 07 08 09 0a 0b +0c 0d 0e 0f 10 00 01 02 03 04 05 06 07 08 09 0a +0b 0c 0d 0e 0f 10 00 01 02 03 04 05 06 07 08 09 +0a 0b 0c 0d 0e 0f 10 00 01 02 03 04 05 06 07 08 +""") + +RESULT = _strip(""" +c4 42 da 58 5f cb 80 e4 3b 47 94 6f 25 40 93 e3 +73 29 d9 90 01 38 0d b7 83 71 db 3a cf 5c 79 7e +""") + +hexdiff(_k_truncate_output(EncryptionType.AES256_CTS_HMAC_SHA1_96, INPUT), RESULT) +assert _k_truncate_output(EncryptionType.AES256_CTS_HMAC_SHA1_96, INPUT) == RESULT + += RFC4556 Test Vectors - octetstring2key - Set 4 + +INPUT = _strip(""" +00 01 02 03 04 05 06 07 08 09 0a 0b 0c 0d 0e 0f +10 00 01 02 03 04 05 06 07 08 09 0a 0b 0c 0d 0e +0f 10 00 01 02 03 04 05 06 07 08 09 0a 0b 0c 0d +0e 0f 10 00 01 02 03 04 05 06 07 08 09 0a 0b 0c +0d 0e 0f 10 00 01 02 03 04 05 06 07 08 +""") + +RESULT = _strip(""" +00 53 95 3b 84 c8 96 f4 eb 38 5c 3f 2e 75 1c 4a +59 0e d6 ff ad ca 6f f6 4f 47 eb eb 8d 78 0f fc +""") + +hexdiff(_k_truncate_output(EncryptionType.AES256_CTS_HMAC_SHA1_96, INPUT), RESULT) +assert _k_truncate_output(EncryptionType.AES256_CTS_HMAC_SHA1_96, INPUT) == RESULT + + GSS-API KerberosSSP tests ~ mock diff --git a/test/scapy/layers/tls/cert.uts b/test/scapy/layers/tls/cert.uts index f0a258e4db4..ace1b75e1dc 100644 --- a/test/scapy/layers/tls/cert.uts +++ b/test/scapy/layers/tls/cert.uts @@ -634,106 +634,6 @@ expected_repr = """__ /C=US/ST=Arizona/L=Scottsdale/O=Starfield Technologies, In _ /OU=Domain Control Validated/CN=*.tools.ietf.org""" assert str(Chain([c0, c1, c2])) == expected_repr -= Chain class : Checking chain verification -assert Chain([], c0).verifyChain([c2], [c1]) -not Chain([c1]).verifyChain([c0]) - -= Chain class: Checking chain verification with file - -import tempfile - -tf_folder = tempfile.mkdtemp() - -try: - os.makedirs(tf_folder) -except: - pass - -tf = os.path.join(tf_folder, "trusted") -utf = os.path.join(tf_folder, "untrusted") - -tf -utf - -# Create files -trusted = open(tf, "w") -trusted.write(""" ------BEGIN CERTIFICATE----- -MIIFADCCA+igAwIBAgIBBzANBgkqhkiG9w0BAQsFADCBjzELMAkGA1UEBhMCVVMx -EDAOBgNVBAgTB0FyaXpvbmExEzARBgNVBAcTClNjb3R0c2RhbGUxJTAjBgNVBAoT -HFN0YXJmaWVsZCBUZWNobm9sb2dpZXMsIEluYy4xMjAwBgNVBAMTKVN0YXJmaWVs -ZCBSb290IENlcnRpZmljYXRlIEF1dGhvcml0eSAtIEcyMB4XDTExMDUwMzA3MDAw -MFoXDTMxMDUwMzA3MDAwMFowgcYxCzAJBgNVBAYTAlVTMRAwDgYDVQQIEwdBcml6 -b25hMRMwEQYDVQQHEwpTY290dHNkYWxlMSUwIwYDVQQKExxTdGFyZmllbGQgVGVj -aG5vbG9naWVzLCBJbmMuMTMwMQYDVQQLEypodHRwOi8vY2VydHMuc3RhcmZpZWxk -dGVjaC5jb20vcmVwb3NpdG9yeS8xNDAyBgNVBAMTK1N0YXJmaWVsZCBTZWN1cmUg -Q2VydGlmaWNhdGUgQXV0aG9yaXR5IC0gRzIwggEiMA0GCSqGSIb3DQEBAQUAA4IB -DwAwggEKAoIBAQDlkGZL7PlGcakgg77pbL9KyUhpgXVObST2yxcT+LBxWYR6ayuF -pDS1FuXLzOlBcCykLtb6Mn3hqN6UEKwxwcDYav9ZJ6t21vwLdGu4p64/xFT0tDFE -3ZNWjKRMXpuJyySDm+JXfbfYEh/JhW300YDxUJuHrtQLEAX7J7oobRfpDtZNuTlV -Bv8KJAV+L8YdcmzUiymMV33a2etmGtNPp99/UsQwxaXJDgLFU793OGgGJMNmyDd+ -MB5FcSM1/5DYKp2N57CSTTx/KgqT3M0WRmX3YISLdkuRJ3MUkuDq7o8W6o0OPnYX -v32JgIBEQ+ct4EMJddo26K3biTr1XRKOIwSDAgMBAAGjggEsMIIBKDAPBgNVHRMB -Af8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQUJUWBaFAmOD07LSy+ -zWrZtj2zZmMwHwYDVR0jBBgwFoAUfAwyH6fZMH/EfWijYqihzqsHWycwOgYIKwYB -BQUHAQEELjAsMCoGCCsGAQUFBzABhh5odHRwOi8vb2NzcC5zdGFyZmllbGR0ZWNo -LmNvbS8wOwYDVR0fBDQwMjAwoC6gLIYqaHR0cDovL2NybC5zdGFyZmllbGR0ZWNo -LmNvbS9zZnJvb3QtZzIuY3JsMEwGA1UdIARFMEMwQQYEVR0gADA5MDcGCCsGAQUF -BwIBFitodHRwczovL2NlcnRzLnN0YXJmaWVsZHRlY2guY29tL3JlcG9zaXRvcnkv -MA0GCSqGSIb3DQEBCwUAA4IBAQBWZcr+8z8KqJOLGMfeQ2kTNCC+Tl94qGuc22pN -QdvBE+zcMQAiXvcAngzgNGU0+bE6TkjIEoGIXFs+CFN69xpk37hQYcxTUUApS8L0 -rjpf5MqtJsxOYUPl/VemN3DOQyuwlMOS6eFfqhBJt2nk4NAfZKQrzR9voPiEJBjO -eT2pkb9UGBOJmVQRDVXFJgt5T1ocbvlj2xSApAer+rKluYjdkf5lO6Sjeb6JTeHQ -sPTIFwwKlhR8Cbds4cLYVdQYoKpBaXAko7nv6VrcPuuUSvC33l8Odvr7+2kDRUBQ -7nIMpBKGgc0T0U7EPMpODdIm8QC3tKai4W56gf0wrHofx1l7 ------END CERTIFICATE----- -""") -trusted.close() - -untrusted = open(utf, "w") -untrusted.write(""" ------BEGIN CERTIFICATE----- -MIID3TCCAsWgAwIBAgIBADANBgkqhkiG9w0BAQsFADCBjzELMAkGA1UEBhMCVVMx -EDAOBgNVBAgTB0FyaXpvbmExEzARBgNVBAcTClNjb3R0c2RhbGUxJTAjBgNVBAoT -HFN0YXJmaWVsZCBUZWNobm9sb2dpZXMsIEluYy4xMjAwBgNVBAMTKVN0YXJmaWVs -ZCBSb290IENlcnRpZmljYXRlIEF1dGhvcml0eSAtIEcyMB4XDTA5MDkwMTAwMDAw -MFoXDTM3MTIzMTIzNTk1OVowgY8xCzAJBgNVBAYTAlVTMRAwDgYDVQQIEwdBcml6 -b25hMRMwEQYDVQQHEwpTY290dHNkYWxlMSUwIwYDVQQKExxTdGFyZmllbGQgVGVj -aG5vbG9naWVzLCBJbmMuMTIwMAYDVQQDEylTdGFyZmllbGQgUm9vdCBDZXJ0aWZp -Y2F0ZSBBdXRob3JpdHkgLSBHMjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoC -ggEBAL3twQP89o/8ArFvW59I2Z154qK3A2FWGMNHttfKPTUuiUP3oWmb3ooa/RMg -nLRJdzIpVv257IzdIvpy3Cdhl+72WoTsbhm5iSzchFvVdPtrX8WJpRBSiUZV9Lh1 -HOZ/5FSuS/hVclcCGfgXcVnrHigHdMWdSL5stPSksPNkN3mSwOxGXn/hbVNMYq/N -Hwtjuzqd+/x5AJhhdM8mgkBj87JyahkNmcrUDnXMN/uLicFZ8WJ/X7NfZTD4p7dN -dloedl40wOiWVpmKs/B/pM293DIxfJHP4F8R+GuqSVzRmZTRouNjWwl2tVZi4Ut0 -HZbUJtQIBFnQmA4O5t78w+wfkPECAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAO -BgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYEFHwMMh+n2TB/xH1oo2Kooc6rB1snMA0G -CSqGSIb3DQEBCwUAA4IBAQARWfolTwNvlJk7mh+ChTnUdgWUXuEok21iXQnCoKjU -sHU48TRqneSfioYmUeYs0cYtbpUgSpIB7LiKZ3sx4mcujJUDJi5DnUox9g61DLu3 -4jd/IroAow57UvtruzvE03lRTs2Q9GcHGcg8RnoNAX3FWOdt5oUwF5okxBDgBPfg -8n/Uqgr/Qh037ZTlZFkSIHc40zI+OIF1lnP6aI+xy84fxez6nH7PfrHxBy22/L/K -pL/QlwVKvOoYKAKQvVR4CSFx09F9HdkWsKlhPdAKACL8x3vLCWRFCztAgfd9fDL1 -mMpYjn0q7pBZc2T5NnReJaH1ZgUufzkVqSr7UIuOhWn0 ------END CERTIFICATE----- -""") -untrusted.close() - -assert Chain([], c0).verifyChainFromCAFile(tf, untrusted_file=utf) -assert Chain([], c0).verifyChainFromCAPath(tf_folder, untrusted_file=utf) - -= Clear files - -try: - os.remove("./certs_test_ca/trusted") - os.remove("./certs_test_ca/untrusted") -except: - pass - -try: - os.rmdir("././certs_test_ca") -except: - pass - = Test __repr__ repr_str = Chain([], c0).__repr__() From a2bb2a0db5f959ac52f23e762d8717288cb16289 Mon Sep 17 00:00:00 2001 From: gpotter2 <10530980+gpotter2@users.noreply.github.com> Date: Thu, 25 Sep 2025 13:29:55 +0200 Subject: [PATCH 03/14] NTLM: add old variant support --- scapy/layers/ntlm.py | 151 ++++++++++++++++++++++++++++++++++++------- 1 file changed, 127 insertions(+), 24 deletions(-) diff --git a/scapy/layers/ntlm.py b/scapy/layers/ntlm.py index 556faee0ea5..1f6ab17a50a 100644 --- a/scapy/layers/ntlm.py +++ b/scapy/layers/ntlm.py @@ -94,6 +94,18 @@ ########## +# NTLM structures are all in all very complicated. Many fields don't have a fixed +# position, but are rather referred to with an offset (from the beginning of the +# structure) and a length. In addition to that, there are variants of the structure +# with missing fields when running old versions of Windows (sometimes also seen when +# talking to products that reimplement NTLM, most notably backup applications). + +# We add `_NTLMPayloadField` and `_NTLMPayloadPacket` to parse fields that use an +# offset, and `_NTLM_post_build` to be able to rebuild those offsets. +# In addition, the `NTLM_VARIANT*` allows to select what flavor of NTLM to use +# (NT, XP, or Recent). But in real world use only Recent should be used. + + class _NTLMPayloadField(_StrField[List[Tuple[str, Any]]]): """Special field used to dissect NTLM payloads. This isn't trivial because the offsets are variable.""" @@ -396,6 +408,41 @@ def _NTLM_post_build(self, p, pay_offset, fields, config=_NTLM_CONFIG): ############## +# -- Util: VARIANT class + + +class NTLM_VARIANT(IntEnum): + """ + The message variant to use for NTLM. + """ + + NT_OR_2000 = 0 + XP_OR_2003 = 1 + RECENT = 2 + + +class _NTLM_VARIANT_Packet(_NTLMPayloadPacket): + def __init__(self, *args, **kwargs): + self.VARIANT = kwargs.pop("VARIANT", NTLM_VARIANT.RECENT) + super(_NTLM_VARIANT_Packet, self).__init__(*args, **kwargs) + + def clone_with(self, *args, **kwargs): + pkt = super(_NTLM_VARIANT_Packet, self).clone_with(*args, **kwargs) + pkt.VARIANT = self.VARIANT + return pkt + + def copy(self): + pkt = super(_NTLM_VARIANT_Packet, self).copy() + pkt.VARIANT = self.VARIANT + + return pkt + + def show2(self, dump=False, indent=3, lvl="", label_lvl=""): + return self.__class__(bytes(self), VARIANT=self.VARIANT).show( + dump, indent, lvl, label_lvl + ) + + # Sect 2.2 @@ -488,10 +535,18 @@ class _NTLM_Version(Packet): # Sect 2.2.1.1 -class NTLM_NEGOTIATE(_NTLMPayloadPacket): +class NTLM_NEGOTIATE(_NTLM_VARIANT_Packet): name = "NTLM Negotiate" + __slots__ = ["VARIANT"] MessageType = 1 - OFFSET = lambda pkt: (((pkt.DomainNameBufferOffset or 40) > 32) and 40 or 32) + OFFSET = lambda pkt: ( + 32 + if ( + pkt.VARIANT == NTLM_VARIANT.NT_OR_2000 + or (pkt.DomainNameBufferOffset or 40) <= 32 + ) + else 40 + ) fields_desc = ( [ NTLM_Header, @@ -510,15 +565,18 @@ class NTLM_NEGOTIATE(_NTLMPayloadPacket): ConditionalField( # (not present on some old Windows versions. We use a heuristic) x, - lambda pkt: ( + lambda pkt: pkt.VARIANT >= NTLM_VARIANT.XP_OR_2003 + and ( ( - 40 - if pkt.DomainNameBufferOffset is None - else pkt.DomainNameBufferOffset or len(pkt.original or b"") + ( + 40 + if pkt.DomainNameBufferOffset is None + else pkt.DomainNameBufferOffset or len(pkt.original or b"") + ) + > 32 ) - > 32 - ) - or pkt.fields.get(x.name, b""), + or pkt.fields.get(x.name, b"") + ), ) for x in _NTLM_Version.fields_desc ] @@ -628,10 +686,18 @@ def default_payload_class(self, payload): return conf.padding_layer -class NTLM_CHALLENGE(_NTLMPayloadPacket): +class NTLM_CHALLENGE(_NTLM_VARIANT_Packet): name = "NTLM Challenge" + __slots__ = ["VARIANT"] MessageType = 2 - OFFSET = lambda pkt: (((pkt.TargetInfoBufferOffset or 56) > 48) and 56 or 48) + OFFSET = lambda pkt: ( + 48 + if ( + pkt.VARIANT == NTLM_VARIANT.NT_OR_2000 + or (pkt.TargetInfoBufferOffset or 56) <= 48 + ) + else 56 + ) fields_desc = ( [ NTLM_Header, @@ -653,8 +719,11 @@ class NTLM_CHALLENGE(_NTLMPayloadPacket): ConditionalField( # (not present on some old Windows versions. We use a heuristic) x, - lambda pkt: ((pkt.TargetInfoBufferOffset or 56) > 40) - or pkt.fields.get(x.name, b""), + lambda pkt: pkt.VARIANT >= NTLM_VARIANT.XP_OR_2003 + and ( + ((pkt.TargetInfoBufferOffset or 56) > 40) + or pkt.fields.get(x.name, b"") + ), ) for x in _NTLM_Version.fields_desc ] @@ -770,14 +839,23 @@ def computeNTProofStr(self, ResponseKeyNT, ServerChallenge): return HMAC_MD5(ResponseKeyNT, ServerChallenge + temp) -class NTLM_AUTHENTICATE(_NTLMPayloadPacket): +class NTLM_AUTHENTICATE(_NTLM_VARIANT_Packet): name = "NTLM Authenticate" + __slots__ = ["VARIANT"] MessageType = 3 NTLM_VERSION = 1 OFFSET = lambda pkt: ( - ((pkt.DomainNameBufferOffset or 88) <= 64) - and 64 - or (((pkt.DomainNameBufferOffset or 88) > 72) and 88 or 72) + 64 + if ( + pkt.VARIANT == NTLM_VARIANT.NT_OR_2000 + or (pkt.DomainNameBufferOffset or 88) <= 64 + ) + else ( + 72 + if pkt.VARIANT == NTLM_VARIANT.XP_OR_2003 + or ((pkt.DomainNameBufferOffset or 88) <= 72) + else 88 + ) ) fields_desc = ( [ @@ -814,8 +892,11 @@ class NTLM_AUTHENTICATE(_NTLMPayloadPacket): ConditionalField( # (not present on some old Windows versions. We use a heuristic) x, - lambda pkt: ((pkt.DomainNameBufferOffset or 88) > 64) - or pkt.fields.get(x.name, b""), + lambda pkt: pkt.VARIANT >= NTLM_VARIANT.XP_OR_2003 + and ( + ((pkt.DomainNameBufferOffset or 88) > 64) + or pkt.fields.get(x.name, b"") + ), ) for x in _NTLM_Version.fields_desc ] @@ -824,8 +905,11 @@ class NTLM_AUTHENTICATE(_NTLMPayloadPacket): ConditionalField( # (not present on some old Windows versions. We use a heuristic) XStrFixedLenField("MIC", b"", length=16), - lambda pkt: ((pkt.DomainNameBufferOffset or 88) > 72) - or pkt.fields.get("MIC", b""), + lambda pkt: pkt.VARIANT >= NTLM_VARIANT.RECENT + and ( + ((pkt.DomainNameBufferOffset or 88) > 72) + or pkt.fields.get("MIC", b"") + ), ), # Payload _NTLMPayloadField( @@ -1247,6 +1331,7 @@ def __init__( HASHNT=None, PASSWORD=None, USE_MIC=True, + VARIANT: NTLM_VARIANT = NTLM_VARIANT.RECENT, NTLM_VALUES={}, DOMAIN_FQDN=None, DOMAIN_NB_NAME=None, @@ -1261,7 +1346,14 @@ def __init__( if HASHNT is None and PASSWORD is not None: HASHNT = MD4le(PASSWORD) self.HASHNT = HASHNT - self.USE_MIC = USE_MIC + self.VARIANT = VARIANT + if self.VARIANT != NTLM_VARIANT.RECENT: + log_runtime.warning( + "VARIANT != NTLM_VARIANT.RECENT. You shouldn't touch this !" + ) + self.USE_MIC = False + else: + self.USE_MIC = USE_MIC self.NTLM_VALUES = NTLM_VALUES if UPN is not None: from scapy.layers.kerberos import _parse_upn @@ -1399,6 +1491,7 @@ def GSS_Init_sec_context( # Client: negotiate # Create a default token tok = NTLM_NEGOTIATE( + VARIANT=self.VARIANT, NegotiateFlags="+".join( [ "NEGOTIATE_UNICODE", @@ -1408,10 +1501,14 @@ def GSS_Init_sec_context( "TARGET_TYPE_DOMAIN", "NEGOTIATE_EXTENDED_SESSIONSECURITY", "NEGOTIATE_TARGET_INFO", - "NEGOTIATE_VERSION", "NEGOTIATE_128", "NEGOTIATE_56", ] + + ( + ["NEGOTIATE_VERSION"] + if self.VARIANT >= NTLM_VARIANT.XP_OR_2003 + else [] + ) + ( [ "NEGOTIATE_KEY_EXCH", @@ -1466,6 +1563,7 @@ def GSS_Init_sec_context( return Context, None, GSS_S_DEFECTIVE_TOKEN # Take a default token tok = NTLM_AUTHENTICATE_V2( + VARIANT=self.VARIANT, NegotiateFlags=chall_tok.NegotiateFlags, ProductMajorVersion=10, ProductMinorVersion=0, @@ -1618,6 +1716,7 @@ def GSS_Accept_sec_context( # Take a default token currentTime = (time.time() + 11644473600) * 1e7 tok = NTLM_CHALLENGE( + VARIANT=self.VARIANT, ServerChallenge=self.SERVER_CHALLENGE or os.urandom(8), NegotiateFlags="+".join( [ @@ -1628,11 +1727,15 @@ def GSS_Accept_sec_context( "NEGOTIATE_EXTENDED_SESSIONSECURITY", "NEGOTIATE_TARGET_INFO", "TARGET_TYPE_DOMAIN", - "NEGOTIATE_VERSION", "NEGOTIATE_128", "NEGOTIATE_KEY_EXCH", "NEGOTIATE_56", ] + + ( + ["NEGOTIATE_VERSION"] + if self.VARIANT >= NTLM_VARIANT.XP_OR_2003 + else [] + ) + ( ["NEGOTIATE_SIGN"] if nego_tok.NegotiateFlags.NEGOTIATE_SIGN From d931590aa993ef3f45560ddf516960f91cd1d81d Mon Sep 17 00:00:00 2001 From: gpotter2 <10530980+gpotter2@users.noreply.github.com> Date: Sun, 19 Oct 2025 14:57:49 +0200 Subject: [PATCH 04/14] DCE/RPC: improve context handling --- scapy/layers/dcerpc.py | 11 +- scapy/layers/msrpce/rpcclient.py | 215 +++++++++++++++++++++---------- 2 files changed, 158 insertions(+), 68 deletions(-) diff --git a/scapy/layers/dcerpc.py b/scapy/layers/dcerpc.py index 28dfa6f97a0..7174e59993b 100644 --- a/scapy/layers/dcerpc.py +++ b/scapy/layers/dcerpc.py @@ -451,6 +451,14 @@ class RPC_C_AUTHN_LEVEL(IntEnum): DCE_C_AUTHN_LEVEL = RPC_C_AUTHN_LEVEL # C706 name +class RPC_C_IMP_LEVEL(IntEnum): + DEFAULT = 0x0 + ANONYMOUS = 0x1 + IDENTIFY = 0x2 + IMPERSONATE = 0x3 + DELEGATE = 0x4 + + # C706 sect 13.2.6.1 @@ -2766,9 +2774,9 @@ def __init__(self, *args, **kwargs): self.ssp = kwargs.pop("ssp", None) self.sspcontext = kwargs.pop("sspcontext", None) self.auth_level = kwargs.pop("auth_level", None) - self.auth_context_id = kwargs.pop("auth_context_id", 0) self.sent_cont_ids = [] self.cont_id = 0 # Currently selected context + self.auth_context_id = 0 # Currently selected authentication context self.map_callid_opnum = {} self.frags = collections.defaultdict(lambda: b"") self.sniffsspcontexts = {} # Unfinished contexts for passive @@ -3283,7 +3291,6 @@ def __init__(self, *args, **kwargs): self.session = DceRpcSession( ssp=kwargs.pop("ssp", None), auth_level=kwargs.pop("auth_level", None), - auth_context_id=kwargs.pop("auth_context_id", None), support_header_signing=kwargs.pop("support_header_signing", True), ) super(DceRpcSocket, self).__init__(*args, **kwargs) diff --git a/scapy/layers/msrpce/rpcclient.py b/scapy/layers/msrpce/rpcclient.py index a25f6587126..b4c0544210e 100644 --- a/scapy/layers/msrpce/rpcclient.py +++ b/scapy/layers/msrpce/rpcclient.py @@ -41,6 +41,7 @@ find_dcerpc_interface, NDRContextHandle, NDRPointer, + RPC_C_IMP_LEVEL, ) from scapy.layers.gssapi import ( SSP, @@ -80,6 +81,7 @@ class DCERPC_Client(object): :param ndrendian: the endianness to use (default little) :param verb: enable verbose logging (default True) :param auth_level: the DCE_C_AUTHN_LEVEL to use + :param impersonation_type: the RPC_C_IMP_LEVEL to use """ def __init__( @@ -89,7 +91,7 @@ def __init__( ndrendian: str = "little", verb: bool = True, auth_level: Optional[DCE_C_AUTHN_LEVEL] = None, - auth_context_id: int = 0, + impersonation_type: RPC_C_IMP_LEVEL = RPC_C_IMP_LEVEL.DEFAULT, **kwargs, ): self.sock = None @@ -100,7 +102,8 @@ def __init__( # Counters self.call_id = 0 - self.all_cont_id = 0 # number of contexts sent + self.next_cont_id = 0 # next available context id + self.next_auth_contex_id = 0 # next available auth context id # Session parameters if ndr64 is None: @@ -118,8 +121,12 @@ def __init__( self.auth_level = DCE_C_AUTHN_LEVEL.CONNECT else: self.auth_level = DCE_C_AUTHN_LEVEL.NONE - self.auth_context_id = auth_context_id + if impersonation_type == RPC_C_IMP_LEVEL.DEFAULT: + # Same default as windows + impersonation_type = RPC_C_IMP_LEVEL.IDENTIFY + self.impersonation_type = impersonation_type self._first_time_on_interface = True + self.contexts = {} self.dcesockargs = kwargs self.dcesockargs["transport"] = self.transport @@ -135,7 +142,6 @@ def from_smblink(cls, smbcli, smb_kwargs={}, **kwargs): DceRpc5, ssp=client.ssp, auth_level=client.auth_level, - auth_context_id=client.auth_context_id, **client.dcesockargs, ) return client @@ -144,23 +150,71 @@ def from_smblink(cls, smbcli, smb_kwargs={}, **kwargs): def session(self) -> DceRpcSession: return self.sock.session - def connect(self, host, port=None, timeout=5, smb_kwargs={}): + def connect( + self, + host, + endpoint: Union[int, str] = None, + port: Optional[int] = None, + interface=None, + timeout=5, + smb_kwargs={}, + ): """ Initiate a connection. :param host: the host to connect to - :param port: (optional) the port to connect to + :param endpoint: (optional) the port/smb pipe to connect to + :param interface: (optional) if endpoint isn't provided, uses the endpoint + mapper to find the appropriate endpoint for that interface. :param timeout: (optional) the connection timeout (default 5) + :param port: (optional) the port to connect to. (useful for SMB) """ + if endpoint is None and interface is not None: + # Figure out the endpoint using the endpoint mapper + + if self.transport == DCERPC_Transport.NCACN_IP_TCP and port is None: + # IP/TCP + # ask the endpoint mapper (port 135) for the IP:PORT + endpoints = get_endpoint( + host, + interface, + ndrendian=self.ndrendian, + verb=self.verb, + ) + if endpoints: + _, endpoint = endpoints[0] + else: + raise ValueError( + "Could not find an available endpoint for that interface !" + ) + elif self.transport == DCERPC_Transport.NCACN_NP: + # SMB + # ask the endpoint mapper (over SMB) for the namedpipe + endpoints = get_endpoint( + host, + interface, + transport=self.transport, + ndrendian=self.ndrendian, + verb=self.verb, + smb_kwargs=smb_kwargs, + ) + if endpoints: + endpoint = endpoints[0].lstrip("\\pipe\\") + else: + return + + # Assign the default port if no port is provided if port is None: if self.transport == DCERPC_Transport.NCACN_IP_TCP: # IP/TCP - port = 135 + port = endpoint or 135 elif self.transport == DCERPC_Transport.NCACN_NP: # SMB port = 445 else: raise ValueError( "Can't guess the port for transport: %s" % self.transport ) + + # Start socket and connect self.host = host self.port = port sock = socket.socket() @@ -177,7 +231,12 @@ def connect(self, host, port=None, timeout=5, smb_kwargs={}): "\u2514 Connected from %s" % repr(sock.getsockname()) ) ) + if self.transport == DCERPC_Transport.NCACN_NP: # SMB + # If the endpoint is provided, connect to it. + if endpoint is not None: + self.open_smbpipe(endpoint) + # We pack the socket into a SMB_RPC_SOCKET sock = self.smbrpcsock = SMB_RPC_SOCKET.from_tcpsock( sock, ssp=self.ssp, **smb_kwargs @@ -189,7 +248,6 @@ def connect(self, host, port=None, timeout=5, smb_kwargs={}): DceRpc5, ssp=self.ssp, auth_level=self.auth_level, - auth_context_id=self.auth_context_id, **self.dcesockargs, ) @@ -347,10 +405,15 @@ def _get_bind_context(self, interface): """ Internal: get the bind DCE/RPC context. """ + if interface in self.contexts: + # We have already found acceptable contexts for this interface, + # re-use that. + return self.contexts[interface] + # NDR 2.0 contexts = [ DceRpc5Context( - cont_id=self.all_cont_id, + cont_id=self.next_cont_id, abstract_syntax=DceRpc5AbstractSyntax( if_uuid=interface.uuid, if_version=interface.if_version, @@ -364,13 +427,13 @@ def _get_bind_context(self, interface): ], ), ] - self.all_cont_id += 1 + self.next_cont_id += 1 # NDR64 if self.ndr64: contexts.append( DceRpc5Context( - cont_id=self.all_cont_id, + cont_id=self.next_cont_id, abstract_syntax=DceRpc5AbstractSyntax( if_uuid=interface.uuid, if_version=interface.if_version, @@ -384,12 +447,12 @@ def _get_bind_context(self, interface): ], ) ) - self.all_cont_id += 1 + self.next_cont_id += 1 # BindTimeFeatureNegotiationBitmask contexts.append( DceRpc5Context( - cont_id=self.all_cont_id, + cont_id=self.next_cont_id, abstract_syntax=DceRpc5AbstractSyntax( if_uuid=interface.uuid, if_version=interface.if_version, @@ -402,11 +465,28 @@ def _get_bind_context(self, interface): ], ) ) - self.all_cont_id += 1 + self.next_cont_id += 1 + + # Store contexts for this interface + self.contexts[interface] = contexts return contexts - def _bind(self, interface: Union[DceRpcInterface, ComInterface], reqcls, respcls): + def _check_bind_context(self, interface, contexts) -> bool: + """ + Internal: check the answer DCE/RPC bind context, and update them. + """ + for i, ctx in enumerate(contexts): + if ctx.result == 0: + # Context was accepted. Remove all others from cache + self.contexts[interface] = [self.contexts[interface][i]] + return True + + return False + + def _bind( + self, interface: Union[DceRpcInterface, ComInterface], reqcls, respcls + ) -> bool: """ Internal: used to send a bind/alter request """ @@ -418,6 +498,7 @@ def _bind(self, interface: Union[DceRpcInterface, ComInterface], reqcls, respcls + (" (with %s)" % self.ssp.__class__.__name__ if self.ssp else "") ) ) + # Do we need an authenticated bind if not self.ssp or ( self.sspcontext is not None @@ -452,13 +533,25 @@ def _bind(self, interface: Union[DceRpcInterface, ComInterface], reqcls, respcls if self.auth_level >= DCE_C_AUTHN_LEVEL.PKT_PRIVACY else 0 ) + | ( + GSS_C_FLAGS.GSS_C_IDENTIFY_FLAG + if self.impersonation_type <= RPC_C_IMP_LEVEL.IDENTIFY + else 0 + ) + | ( + GSS_C_FLAGS.GSS_C_DELEG_FLAG + if self.impersonation_type == RPC_C_IMP_LEVEL.DELEGATE + else 0 + ) ), target_name="host/" + self.host, ) + if status not in [GSS_S_CONTINUE_NEEDED, GSS_S_COMPLETE]: # Authentication failed. self.sspcontext.clifailure() return False + resp = self.sr1( reqcls(context_elem=self._get_bind_context(interface)), auth_verifier=( @@ -467,7 +560,7 @@ def _bind(self, interface: Union[DceRpcInterface, ComInterface], reqcls, respcls else CommonAuthVerifier( auth_type=self.ssp.auth_type, auth_level=self.auth_level, - auth_context_id=self.auth_context_id, + auth_context_id=self.session.auth_context_id, auth_value=token, ) ), @@ -481,7 +574,11 @@ def _bind(self, interface: Union[DceRpcInterface, ComInterface], reqcls, respcls ) ), ) - if respcls not in resp: + + # Check that the answer looks valid and contexts were accepted + if respcls not in resp or not self._check_bind_context( + interface, resp.results + ): token = None status = GSS_S_FAILURE else: @@ -491,6 +588,7 @@ def _bind(self, interface: Union[DceRpcInterface, ComInterface], reqcls, respcls token=resp.auth_verifier.auth_value, target_name="host/" + self.host, ) + if status in [GSS_S_CONTINUE_NEEDED, GSS_S_COMPLETE]: # Authentication should continue, in two ways: # - through DceRpc5Auth3 (e.g. NTLM) @@ -503,7 +601,7 @@ def _bind(self, interface: Union[DceRpcInterface, ComInterface], reqcls, respcls auth_verifier=CommonAuthVerifier( auth_type=self.ssp.auth_type, auth_level=self.auth_level, - auth_context_id=self.auth_context_id, + auth_context_id=self.session.auth_context_id, auth_value=token, ), ) @@ -518,7 +616,7 @@ def _bind(self, interface: Union[DceRpcInterface, ComInterface], reqcls, respcls auth_verifier=CommonAuthVerifier( auth_type=self.ssp.auth_type, auth_level=self.auth_level, - auth_context_id=self.auth_context_id, + auth_context_id=self.session.auth_context_id, auth_value=token, ), ) @@ -535,17 +633,17 @@ def _bind(self, interface: Union[DceRpcInterface, ComInterface], reqcls, respcls ) else: log_runtime.error("GSS_Init_sec_context failed with %s !" % status) + # Check context acceptance if ( status == GSS_S_COMPLETE and respcls in resp - and any(x.result == 0 for x in resp.results[: int(self.ndr64) + 1]) + and self._check_bind_context(interface, resp.results) ): self.call_id = 0 # reset call id port = resp.sec_addr.port_spec.decode() ndr = self.session.ndr64 and "NDR64" or "NDR32" self.ndr64 = self.session.ndr64 - self.cont_id = int(self.session.ndr64) # ctx 0 for NDR32, 1 for NDR64 if self.verb: print( conf.color_theme.success( @@ -592,7 +690,7 @@ def _bind(self, interface: Union[DceRpcInterface, ComInterface], reqcls, respcls resp.show() return False - def bind(self, interface: Union[DceRpcInterface, ComInterface]): + def bind(self, interface: Union[DceRpcInterface, ComInterface]) -> bool: """ Bind the client to an interface @@ -600,7 +698,7 @@ def bind(self, interface: Union[DceRpcInterface, ComInterface]): """ return self._bind(interface, DceRpc5Bind, DceRpc5BindAck) - def alter_context(self, interface: Union[DceRpcInterface, ComInterface]): + def alter_context(self, interface: Union[DceRpcInterface, ComInterface]) -> bool: """ Alter context: post-bind context negotiation @@ -608,7 +706,7 @@ def alter_context(self, interface: Union[DceRpcInterface, ComInterface]): """ return self._bind(interface, DceRpc5AlterContext, DceRpc5AlterContextResp) - def bind_or_alter(self, interface: Union[DceRpcInterface, ComInterface]): + def bind_or_alter(self, interface: Union[DceRpcInterface, ComInterface]) -> bool: """ Bind the client to an interface or alter the context if already bound @@ -616,10 +714,11 @@ def bind_or_alter(self, interface: Union[DceRpcInterface, ComInterface]): """ if not self.session.rpc_bind_interface: # No interface is bound - self.bind(interface) + return self.bind(interface) elif self.session.rpc_bind_interface != interface: # An interface is already bound - self.alter_context(interface) + return self.alter_context(interface) + return True def open_smbpipe(self, name: str): """ @@ -640,7 +739,7 @@ def close_smbpipe(self): def connect_and_bind( self, - ip: str, + host: str, interface: DceRpcInterface, port: Optional[int] = None, timeout: int = 5, @@ -650,45 +749,20 @@ def connect_and_bind( Asks the Endpoint Mapper what address to use to connect to the interface, then uses connect() followed by a bind() - :param ip: the ip to connect to + :param host: the host to connect to :param interface: the DceRpcInterface object :param port: (optional, NCACN_NP only) the port to connect to :param timeout: (optional) the connection timeout (default 5) """ - if self.transport == DCERPC_Transport.NCACN_IP_TCP: - # IP/TCP - # 1. ask the endpoint mapper (port 135) for the IP:PORT - endpoints = get_endpoint( - ip, - interface, - ndrendian=self.ndrendian, - verb=self.verb, - ) - if endpoints: - ip, port = endpoints[0] - else: - return - # 2. Connect to that IP:PORT - self.connect(ip, port=port, timeout=timeout) - elif self.transport == DCERPC_Transport.NCACN_NP: - # SMB - # 1. ask the endpoint mapper (over SMB) for the namedpipe - endpoints = get_endpoint( - ip, - interface, - transport=self.transport, - ndrendian=self.ndrendian, - verb=self.verb, - smb_kwargs=smb_kwargs, - ) - if endpoints: - pipename = endpoints[0].lstrip("\\pipe\\") - else: - return - # 2. connect to the SMB server - self.connect(ip, port=port, timeout=timeout, smb_kwargs=smb_kwargs) - # 3. open the new named pipe - self.open_smbpipe(pipename) + # Connect to the interface using the endpoint mapper + self.connect( + host=host, + interface=interface, + port=port, + timeout=timeout, + smb_kwargs=smb_kwargs, + ) + # Bind in RPC self.bind(interface) @@ -861,15 +935,24 @@ def get_endpoint( """ client = DCERPC_Client( transport, + # EPM only works with NDR32 ndr64=False, ndrendian=ndrendian, verb=verb, ssp=ssp, - ) # EPM only works with NDR32 - client.connect(ip, smb_kwargs=smb_kwargs) - if transport == DCERPC_Transport.NCACN_NP: # SMB - client.open_smbpipe("epmapper") + ) + + if transport == DCERPC_Transport.NCACN_IP_TCP: + endpoint = 135 + elif transport == DCERPC_Transport.NCACN_NP: + endpoint = "epmapper" + else: + raise ValueError("Unknown transport value !") + + client.connect(ip, endpoint=endpoint, smb_kwargs=smb_kwargs) + client.bind(find_dcerpc_interface("ept")) endpoints = client.epm_map(interface) + client.close() return endpoints From 755b70073d7120c3b8c5bb367ae143025024d3ec Mon Sep 17 00:00:00 2001 From: gpotter2 <10530980+gpotter2@users.noreply.github.com> Date: Sun, 19 Oct 2025 14:58:43 +0200 Subject: [PATCH 05/14] Kerberos: fix passive with DCE/RPC + improve deleg --- scapy/layers/kerberos.py | 157 +++++++++++++++++++++++++++------------ 1 file changed, 111 insertions(+), 46 deletions(-) diff --git a/scapy/layers/kerberos.py b/scapy/layers/kerberos.py index a4485a14ab2..28622c47526 100644 --- a/scapy/layers/kerberos.py +++ b/scapy/layers/kerberos.py @@ -390,6 +390,9 @@ def get_usage(self): elif isinstance(self.underlayer, KRB_AS_REP): # AS-REP encrypted part return 3, EncASRepPart + elif isinstance(self.underlayer, KRB_KDC_REQ_BODY): + # KDC-REQ enc-authorization-data + return 4, AuthorizationData elif isinstance(self.underlayer, KRB_AP_REQ) and isinstance( self.underlayer.underlayer, PADATA ): @@ -982,9 +985,10 @@ class KERB_AD_RESTRICTION_ENTRY(ASN1_Packet): class KERB_AUTH_DATA_AP_OPTIONS(Packet): name = "KERB-AUTH-DATA-AP-OPTIONS" fields_desc = [ - LEIntEnumField( + FlagsField( "apOptions", 0x4000, + -32, { 0x4000: "KERB_AP_OPTIONS_CBT", 0x8000: "KERB_AP_OPTIONS_UNVERIFIED_TARGET_NAME", @@ -1809,6 +1813,12 @@ class KRB_AS_REP(ASN1_Packet): implicit_tag=ASN1_Class_KRB.AS_REP, ) + def getUPN(self): + return "%s@%s" % ( + self.cname.toString(), + self.crealm.val.decode(), + ) + class KRB_TGS_REP(ASN1_Packet): ASN1_codec = ASN1_Codecs.BER @@ -2420,11 +2430,11 @@ class KRB_AuthenticatorChecksum(Packet): }, ), ConditionalField( - LEShortField("DlgOpt", 0), + LEShortField("DlgOpt", 1), lambda pkt: pkt.Flags.GSS_C_DELEG_FLAG, ), ConditionalField( - FieldLenField("Dlgth", None, length_of="Deleg"), + FieldLenField("Dlgth", None, length_of="Deleg", fmt=" Date: Sun, 19 Oct 2025 14:58:56 +0200 Subject: [PATCH 06/14] MS-NRPC: support Kerberos secure channel --- scapy/layers/msrpce/msnrpc.py | 206 ++++++++++++++++++++++------------ scapy/layers/ntlm.py | 29 +++-- 2 files changed, 152 insertions(+), 83 deletions(-) diff --git a/scapy/layers/msrpce/msnrpc.py b/scapy/layers/msrpce/msnrpc.py index fef1007e562..e7610a20cc9 100644 --- a/scapy/layers/msrpce/msnrpc.py +++ b/scapy/layers/msrpce/msnrpc.py @@ -22,6 +22,7 @@ NL_AUTH_MESSAGE, NL_AUTH_SIGNATURE, ) +from scapy.layers.kerberos import KerberosSSP, _parse_upn from scapy.layers.gssapi import ( GSS_C_FLAGS, GSS_C_NO_CHANNEL_BINDINGS, @@ -29,8 +30,9 @@ GSS_S_CONTINUE_NEEDED, GSS_S_FAILURE, GSS_S_FLAGS, + SSP, ) -from scapy.layers.ntlm import RC4, RC4K, RC4Init, SSP +from scapy.layers.ntlm import RC4, RC4K, RC4Init, MD4le from scapy.layers.msrpce.rpcclient import ( DCERPC_Client, @@ -40,6 +42,8 @@ from scapy.layers.msrpce.raw.ms_nrpc import ( NetrServerAuthenticate3_Request, NetrServerAuthenticate3_Response, + NetrServerAuthenticateKerberos_Request, + NetrServerAuthenticateKerberos_Response, NetrServerReqChallenge_Request, NetrServerReqChallenge_Response, NETLOGON_SECURE_CHANNEL_TYPE, @@ -114,15 +118,17 @@ 0x00200000: "RODC-passthrough", # W: Supports Advanced Encryption Standard (AES) encryption and SHA2 hashing. 0x01000000: "AES", - # Supports Kerberos as the security support provider for secure channel setup. - 0x20000000: "Kerberos", + # Not used. MUST be ignored on receipt. + 0x20000000: "X", # Y: Supports Secure RPC. 0x40000000: "SecureRPC", - # Not used. MUST be ignored on receipt. - 0x80000000: "Z", + # Supports Kerberos as the security support provider for secure channel setup. + 0x80000000: "Kerberos", } _negotiateFlags = FlagsField("", 0, -32, _negotiateFlags).names +# -- CRYPTO + # [MS-NRPC] sect 3.1.4.3.1 @crypto_validator @@ -569,8 +575,8 @@ class NetlogonClient(DCERPC_Client): >>> cli = NetlogonClient() >>> cli.connect_and_bind("192.168.0.100") >>> cli.establish_secure_channel( - ... domainname="DOMAIN", computername="WIN10", - ... HashNT=bytes.fromhex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), + ... UPN="WIN10@DOMAIN", + ... HASHNT=bytes.fromhex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), ... ) """ @@ -583,26 +589,25 @@ def __init__( **kwargs, ): self.interface = find_dcerpc_interface("logon") - self.ndr64 = False # Netlogon doesn't work with NDR64 self.SessionKey = None self.ClientStoredCredential = None self.supportAES = supportAES super(NetlogonClient, self).__init__( DCERPC_Transport.NCACN_IP_TCP, auth_level=auth_level, - ndr64=self.ndr64, verb=verb, **kwargs, ) - def connect_and_bind(self, remoteIP): + def connect(self, host, **kwargs): """ This calls DCERPC_Client's connect_and_bind to bind the 'logon' interface. """ - super(NetlogonClient, self).connect_and_bind(remoteIP, self.interface) - - def alter_context(self): - return super(NetlogonClient, self).alter_context(self.interface) + super(NetlogonClient, self).connect( + host=host, + interface=self.interface, + **kwargs, + ) def create_authenticator(self): """ @@ -653,9 +658,12 @@ def validate_authenticator(self, auth): def establish_secure_channel( self, - computername: str, - domainname: str, - HashNt: bytes, + UPN: str, + DC_FQDN: str, + HASHNT: Optional[bytes] = None, + PASSWORD: Optional[str] = None, + KEY=None, + ssp: Optional[KerberosSSP] = None, mode=NETLOGON_SECURE_CHANNEL_METHOD.NetrServerAuthenticate3, secureChannelType=NETLOGON_SECURE_CHANNEL_TYPE.WorkstationSecureChannel, ): @@ -667,39 +675,34 @@ def establish_secure_channel( :param mode: one of NETLOGON_SECURE_CHANNEL_METHOD. This defines which method to use to establish the secure channel. - :param computername: the netbios computer account name that is used to establish - the secure channel. (e.g. WIN10) - :param domainname: the netbios domain name to connect to (e.g. DOMAIN) - :param HashNt: the HashNT of the computer account. + :param UPN: the UPN of the computer account name that is used to establish + the secure channel. (e.g. WIN10@domain.local) + :param DC_FQDN: the FQDN name of the DC. + + The function then requires one of the following: + + :param HASHNT: the HashNT of the computer account (in Authenticate3 mode). + :param KEY: a Kerberos key to use (in Kerberos mode) + :param PASSWORD: the password of the computer account (any mode). + :param ssp: a KerberosSSP to use (in Kerberos mode) """ - # Flow documented in 3.1.4 Session-Key Negotiation - # and sect 3.4.5.2 for specific calls - clientChall = os.urandom(8) - - # Step 1: NetrServerReqChallenge - netr_server_req_chall_response = self.sr1_req( - NetrServerReqChallenge_Request( - PrimaryName=None, - ComputerName=computername, - ClientChallenge=PNETLOGON_CREDENTIAL( - data=clientChall, - ), - ndr64=self.ndr64, - ndrendian=self.ndrendian, - ) - ) - if ( - NetrServerReqChallenge_Response not in netr_server_req_chall_response - or netr_server_req_chall_response.status != 0 - ): - print( - conf.color_theme.fail( - "! %s" - % STATUS_ERREF.get(netr_server_req_chall_response.status, "Failure") + computername, domainname = _parse_upn(UPN) + + if mode == NETLOGON_SECURE_CHANNEL_METHOD.NetrServerAuthenticate3: + if ssp or KEY: + raise ValueError("Cannot use 'ssp' on 'KEY' in Authenticate3 mode !") + if not HASHNT: + if PASSWORD: + HASHNT = MD4le(PASSWORD) + else: + raise ValueError("Missing either 'PASSWORD' or 'HASHNT' !") + if "." in domainname: + raise ValueError( + "The UPN in Authenticate3 must have a NETBIOS domain name !" ) - ) - netr_server_req_chall_response.show() - raise ValueError + else: + if HASHNT: + raise ValueError("Cannot use 'HASHNT' in Kerberos mode !") # Calc NegotiateFlags NegotiateFlags = FlagValue( @@ -712,23 +715,61 @@ def establish_secure_channel( # We are either using NetrServerAuthenticate3 or NetrServerAuthenticateKerberos if mode == NETLOGON_SECURE_CHANNEL_METHOD.NetrServerAuthenticate3: # We use the legacy NetrServerAuthenticate3 function (NetlogonSSP) - # Step 2: Build the session key + + # Make sure the interface is bound + if not self.bind_or_alter(self.interface): + raise ValueError("Bind failed !") + + # Flow documented in 3.1.4 Session-Key Negotiation + # and sect 3.4.5.2 for specific calls + clientChall = os.urandom(8) + + # Perform NetrServerReqChallenge request + netr_server_req_chall_response = self.sr1_req( + NetrServerReqChallenge_Request( + PrimaryName=None, + ComputerName=computername, + ClientChallenge=PNETLOGON_CREDENTIAL( + data=clientChall, + ), + ndr64=self.ndr64, + ndrendian=self.ndrendian, + ) + ) + if ( + NetrServerReqChallenge_Response not in netr_server_req_chall_response + or netr_server_req_chall_response.status != 0 + ): + print( + conf.color_theme.fail( + "! %s" + % STATUS_ERREF.get( + netr_server_req_chall_response.status, "Failure" + ) + ) + ) + netr_server_req_chall_response.show() + raise ValueError("NetrServerReqChallenge failed !") + + # Build the session key serverChall = netr_server_req_chall_response.ServerChallenge.data if self.supportAES: - SessionKey = ComputeSessionKeyAES(HashNt, clientChall, serverChall) + SessionKey = ComputeSessionKeyAES(HASHNT, clientChall, serverChall) self.ClientStoredCredential = ComputeNetlogonCredentialAES( clientChall, SessionKey ) else: SessionKey = ComputeSessionKeyStrongKey( - HashNt, clientChall, serverChall + HASHNT, clientChall, serverChall ) self.ClientStoredCredential = ComputeNetlogonCredentialDES( clientChall, SessionKey ) + + # Perform Authenticate3 request netr_server_auth3_response = self.sr1_req( NetrServerAuthenticate3_Request( - PrimaryName=None, + PrimaryName="\\\\" + DC_FQDN, AccountName=computername + "$", SecureChannelType=secureChannelType, ComputerName=computername, @@ -740,10 +781,7 @@ def establish_secure_channel( ndrendian=self.ndrendian, ) ) - if ( - NetrServerAuthenticate3_Response not in netr_server_auth3_response - or netr_server_auth3_response.status != 0 - ): + if netr_server_auth3_response.status != 0: # An error occurred. NegotiatedFlags = None if NetrServerAuthenticate3_Response in netr_server_auth3_response: @@ -758,20 +796,8 @@ def establish_secure_channel( % (NegotiatedFlags ^ NegotiateFlags) ) ) + raise ValueError("NetrServerAuthenticate3 failed !") - # Show the error - print( - conf.color_theme.fail( - "! %s" - % STATUS_ERREF.get(netr_server_auth3_response.status, "Failure") - ) - ) - - # If error is unknown, show the packet entirely - if netr_server_auth3_response.status not in STATUS_ERREF: - netr_server_auth3_response.show() - - raise ValueError # Check Server Credential if self.supportAES: if ( @@ -798,10 +824,44 @@ def establish_secure_channel( domainname=domainname, computername=computername, ) + + # Finally alter context (to use the SSP) + if not self.alter_context(self.interface): + raise ValueError("Bind failed !") + elif mode == NETLOGON_SECURE_CHANNEL_METHOD.NetrServerAuthenticateKerberos: + # We use the brand new NetrServerAuthenticateKerberos function NegotiateFlags += "Kerberos" - # TODO - raise NotImplementedError - # Finally alter context (to use the SSP) - self.alter_context() + # Set KerberosSSP and alter context + if ssp: + self.ssp = self.sock.session.ssp = ssp + else: + self.ssp = self.sock.session.ssp = KerberosSSP( + UPN=UPN, + SPN="netlogon/" + DC_FQDN, + PASSWORD=PASSWORD, + KEY=KEY, + ) + if not self.bind_or_alter(self.interface): + raise ValueError("Bind failed !") + + # Send AuthenticateKerberos request + netr_server_authkerb_response = self.sr1_req( + NetrServerAuthenticateKerberos_Request( + PrimaryName="\\\\" + DC_FQDN, + AccountName=computername + "$", + AccountType=secureChannelType, + ComputerName=computername, + NegotiateFlags=int(NegotiateFlags), + ndr64=self.ndr64, + ndrendian=self.ndrendian, + ) + ) + if netr_server_authkerb_response.status != 0: + # An error occured + netr_server_authkerb_response.show() + raise ValueError("NetrServerAuthenticateKerberos failed !") + + # The NRPC session key is in this case the kerberos one + self.SessionKey = self.sspcontext.SessionKey diff --git a/scapy/layers/ntlm.py b/scapy/layers/ntlm.py index 1f6ab17a50a..c0ec9ffd2d0 100644 --- a/scapy/layers/ntlm.py +++ b/scapy/layers/ntlm.py @@ -1356,6 +1356,7 @@ def __init__( self.USE_MIC = USE_MIC self.NTLM_VALUES = NTLM_VALUES if UPN is not None: + # Populate values used only in server mode. from scapy.layers.kerberos import _parse_upn try: @@ -2001,8 +2002,9 @@ class NTLMSSP_DOMAIN(NTLMSSP): mode: :param UPN: the UPN of the machine account to login for Netlogon. - :param HASHNT: the HASHNT of the machine account to use for Netlogon. - :param PASSWORD: the PASSWORD of the machine acconut to use for Netlogon. + :param HASHNT: the HASHNT of the machine account (use Netlogon secure channel). + :param ssp: a KerberosSSP to use (use Kerberos secure channel). + :param PASSWORD: the PASSWORD of the machine account to use for Netlogon. :param DC_IP: (optional) specify the IP of the DC. Examples:: @@ -2035,16 +2037,21 @@ def __init__(self, UPN, *args, timeout=3, ssp=None, **kwargs): ) # Treat specific parameters - self.DC_IP = kwargs.pop("DC_IP", None) - if self.DC_IP is None: + self.DC_HOST = kwargs.pop("DC_HOST", None) + self.DC_NB_NAME = kwargs.pop("DC_NB_NAME", None) + if self.DC_HOST is None: # Get DC_IP from dclocator from scapy.layers.ldap import dclocator - self.DC_IP = dclocator( + dc = dclocator( self.DOMAIN_FQDN, timeout=timeout, debug=kwargs.get("debug", 0), - ).ip + ) + self.DC_HOST = dc.ip + self.DC_FQDN = dc.samlogon.DnsHostName.decode().rstrip(".") + elif self.DC_NB_NAME is None: + raise ValueError("When providing DC_HOST, must provide DC_NB_NAME !") # If logging in via Kerberos self.ssp = ssp @@ -2074,7 +2081,7 @@ def _getSessionBaseKey(self, Context, ntlm): # Create NetlogonClient with PRIVACY client = NetlogonClient() - client.connect_and_bind(self.DC_IP) + client.connect(self.DC_HOST) # Establish the Netlogon secure channel (this will bind) try: @@ -2082,15 +2089,17 @@ def _getSessionBaseKey(self, Context, ntlm): # Login via classic NetlogonSSP client.establish_secure_channel( mode=NETLOGON_SECURE_CHANNEL_METHOD.NetrServerAuthenticate3, - computername=self.COMPUTER_NB_NAME, - domainname=self.DOMAIN_NB_NAME, + UPN=f"{self.COMPUTER_NB_NAME}@{self.DOMAIN_NB_NAME}", + DC_FQDN=self.DC_FQDN, HashNt=self.HASHNT, ) else: # Login via KerberosSSP (Windows 2025) - # TODO client.establish_secure_channel( mode=NETLOGON_SECURE_CHANNEL_METHOD.NetrServerAuthenticateKerberos, + UPN=self.UPN, + DC_FQDN=self.DC_FQDN, + ssp=self.ssp, ) except ValueError: log_runtime.warning( From cd4b7a5187a5879def8d9ee7d9a61963bb252beb Mon Sep 17 00:00:00 2001 From: gpotter2 <10530980+gpotter2@users.noreply.github.com> Date: Sun, 2 Nov 2025 12:34:21 +0100 Subject: [PATCH 07/14] Fix case in Kerberos check --- scapy/layers/kerberos.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scapy/layers/kerberos.py b/scapy/layers/kerberos.py index 28622c47526..fdac3745922 100644 --- a/scapy/layers/kerberos.py +++ b/scapy/layers/kerberos.py @@ -3136,7 +3136,7 @@ def __init__( if mode in [self.MODE.AS_REQ, self.MODE.GET_SALT]: if not host: raise ValueError("Invalid host") - if x509 is None and (not x509key or not ca): + if x509 is not None and (not x509key or not ca): raise ValueError("Must provide both 'x509', 'x509key' and 'ca' !") elif mode == self.MODE.TGS_REQ: if not ticket: From 8d9d1f3ea93f065808207b6c1d2ead075862444a Mon Sep 17 00:00:00 2001 From: gpotter2 <10530980+gpotter2@users.noreply.github.com> Date: Wed, 26 Nov 2025 18:47:28 +0100 Subject: [PATCH 08/14] HTTP client: allow to drop channel bindings --- scapy/layers/http.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/scapy/layers/http.py b/scapy/layers/http.py index 016337738fc..577230e8bf4 100644 --- a/scapy/layers/http.py +++ b/scapy/layers/http.py @@ -763,6 +763,7 @@ class HTTP_Client(object): :param ssl: whether to use HTTPS or not :param ssp: the SSP object to use for binding :param no_check_certificate: with SSL, do not check the certificate + :param no_chan_bindings: force disable sending the channel bindings """ def __init__( @@ -772,6 +773,7 @@ def __init__( sslcontext=None, ssp=None, no_check_certificate=False, + no_chan_bindings=False, ): self.sock = None self._sockinfo = None @@ -781,6 +783,7 @@ def __init__( self.ssp = ssp self.sspcontext = None self.no_check_certificate = no_check_certificate + self.no_chan_bindings = no_chan_bindings self.chan_bindings = GSS_C_NO_CHANNEL_BINDINGS def _connect_or_reuse(self, host, port=None, tls=False, timeout=5): @@ -823,7 +826,7 @@ def _connect_or_reuse(self, host, port=None, tls=False, timeout=5): else: context = self.sslcontext sock = context.wrap_socket(sock, server_hostname=host) - if self.ssp: + if self.ssp and not self.no_chan_bindings: # Compute the channel binding token (CBT) self.chan_bindings = GssChannelBindings.fromssl( ChannelBindingType.TLS_SERVER_END_POINT, From 60291c7a94560966b0cc2b08654818b61ba9c805 Mon Sep 17 00:00:00 2001 From: gpotter2 <10530980+gpotter2@users.noreply.github.com> Date: Wed, 26 Nov 2025 18:57:16 +0100 Subject: [PATCH 09/14] Add Kerberos doc --- doc/scapy/layers/kerberos.rst | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/doc/scapy/layers/kerberos.rst b/doc/scapy/layers/kerberos.rst index 168e2d7ecab..c6af2b34da4 100644 --- a/doc/scapy/layers/kerberos.rst +++ b/doc/scapy/layers/kerberos.rst @@ -68,6 +68,18 @@ This section tries to give many usage examples, but isn't exhaustive. For more d >>> # Using the AES-256-SHA1-96 Kerberos Key >>> t.request_tgt("Administrator@domain.local", key=Key(EncryptionType.AES256_CTS_HMAC_SHA1_96, bytes.fromhex("63a2577d8bf6abeba0847cded36b9aed202c23750eb9c56b6155be1cc946bb1d"))) +- **Request a TGT using PKINIT**: + +.. code:: pycon + + >>> from scapy.libs.rfc3961 import EncryptionType + >>> load_module("ticketer") + >>> t = Ticketer() + >>> # If P12: + >>> t.request_tgt("Administrator@DOMAIN.LOCAL", p12="admin.pfx", ca="ca.pem") + >>> # One could also have used a different cert and key file: + >>> t.request_tgt("Administrator@DOMAIN.LOCAL", x509="admin.cert", x509key="admin.key", ca="ca.pem") + - **Renew a TGT or ST**: .. code:: From 8fe1193a7a2cfb0b830a33527f1a4314bb3ca5c4 Mon Sep 17 00:00:00 2001 From: gpotter2 <10530980+gpotter2@users.noreply.github.com> Date: Wed, 26 Nov 2025 19:15:04 +0100 Subject: [PATCH 10/14] PEP8 fixes --- scapy/layers/kerberos.py | 10 +- scapy/layers/msrpce/msnrpc.py | 12 +- scapy/layers/spnego.py | 3 +- scapy/layers/tls/cert.py | 250 ++++++++++++++++++++-------------- 4 files changed, 165 insertions(+), 110 deletions(-) diff --git a/scapy/layers/kerberos.py b/scapy/layers/kerberos.py index fdac3745922..efc0c4dc211 100644 --- a/scapy/layers/kerberos.py +++ b/scapy/layers/kerberos.py @@ -5100,7 +5100,7 @@ def GSS_Init_sec_context( # Update UPN (could have been canonicalized) self.UPN = res.upn - # Store TGT, + # Store TGT, self.TGT = res.asrep.ticket self.TGTSessionKey = res.sessionkey else: @@ -5134,7 +5134,9 @@ def GSS_Init_sec_context( Context.STSessionKey = self.KEY if Context.flags & GSS_C_FLAGS.GSS_C_DELEG_FLAG: - raise ValueError("Cannot use GSS_C_DELEG_FLAG when passed a service ticket !") + raise ValueError( + "Cannot use GSS_C_DELEG_FLAG when passed a service ticket !" + ) # Save ServerHostname if len(self.ST.sname.nameString) == 2: @@ -5199,7 +5201,6 @@ def GSS_Init_sec_context( # ) # ) - # Build and encrypt the full KRB_Authenticator ap_req.authenticator.encrypt( Context.STSessionKey, @@ -5207,8 +5208,7 @@ def GSS_Init_sec_context( crealm=crealm, cname=PrincipalName.fromUPN(self.UPN), cksum=Checksum( - cksumtype="KRB-AUTHENTICATOR", - checksum=authenticator_checksum + cksumtype="KRB-AUTHENTICATOR", checksum=authenticator_checksum ), ctime=ASN1_GENERALIZED_TIME(now_time), cusec=ASN1_INTEGER(0), diff --git a/scapy/layers/msrpce/msnrpc.py b/scapy/layers/msrpce/msnrpc.py index e7610a20cc9..edfb5352360 100644 --- a/scapy/layers/msrpce/msnrpc.py +++ b/scapy/layers/msrpce/msnrpc.py @@ -676,7 +676,7 @@ def establish_secure_channel( :param mode: one of NETLOGON_SECURE_CHANNEL_METHOD. This defines which method to use to establish the secure channel. :param UPN: the UPN of the computer account name that is used to establish - the secure channel. (e.g. WIN10@domain.local) + the secure channel. (e.g. WIN10$@domain.local) :param DC_FQDN: the FQDN name of the DC. The function then requires one of the following: @@ -687,6 +687,10 @@ def establish_secure_channel( :param ssp: a KerberosSSP to use (in Kerberos mode) """ computername, domainname = _parse_upn(UPN) + # We need to normalize here, since the functions require both the accountname + # and the normal (no dollar) computer name. + if computername.endswith("$"): + computername = computername[:-1] if mode == NETLOGON_SECURE_CHANNEL_METHOD.NetrServerAuthenticate3: if ssp or KEY: @@ -858,7 +862,11 @@ def establish_secure_channel( ndrendian=self.ndrendian, ) ) - if netr_server_authkerb_response.status != 0: + if ( + NetrServerAuthenticateKerberos_Response + not in netr_server_authkerb_response + or netr_server_authkerb_response.status != 0 + ): # An error occured netr_server_authkerb_response.show() raise ValueError("NetrServerAuthenticateKerberos failed !") diff --git a/scapy/layers/spnego.py b/scapy/layers/spnego.py index b9959078f14..aebebe88293 100644 --- a/scapy/layers/spnego.py +++ b/scapy/layers/spnego.py @@ -705,7 +705,7 @@ def from_cli_arguments( if ST is None and ccache is not None: # In this case, load the KerberosSSP from ccache from scapy.modules.ticketer import Ticketer - + # Import into a Ticketer object t = Ticketer() t.open_ccache(ccache) @@ -713,7 +713,6 @@ def from_cli_arguments( # Look for the ticketer that we'll use raise NotImplementedError - ssps.append(t.ssp()) elif ST is None: # In this case, KEY is supposed to be the user's key. diff --git a/scapy/layers/tls/cert.py b/scapy/layers/tls/cert.py index ed2df5a853d..a8d99f27bc9 100644 --- a/scapy/layers/tls/cert.py +++ b/scapy/layers/tls/cert.py @@ -140,21 +140,24 @@ # loading huge file when importing a cert _MAX_KEY_SIZE = 50 * 1024 _MAX_CERT_SIZE = 50 * 1024 -_MAX_CRL_SIZE = 10 * 1024 * 1024 # some are that big +_MAX_CRL_SIZE = 10 * 1024 * 1024 # some are that big ##################################################################### # Some helpers ##################################################################### + @conf.commands.register def der2pem(der_string, obj="UNKNOWN"): """Convert DER octet string to PEM format (with optional header)""" # Encode a byte string in PEM format. Header advertises type. pem_string = "-----BEGIN %s-----\n" % obj base64_string = base64.b64encode(der_string).decode() - chunks = [base64_string[i:i + 64] for i in range(0, len(base64_string), 64)] # noqa: E501 - pem_string += '\n'.join(chunks) + chunks = [ + base64_string[i : i + 64] for i in range(0, len(base64_string), 64) + ] # noqa: E501 + pem_string += "\n".join(chunks) pem_string += "\n-----END %s-----\n" % obj return pem_string @@ -215,7 +218,7 @@ def __call__(cls, obj_path, obj_max_size, pem_marker=None): raise Exception(error_msg) obj_path = bytes_encode(obj_path) - if (b'\x00' not in obj_path) and os.path.isfile(obj_path): + if (b"\x00" not in obj_path) and os.path.isfile(obj_path): _size = os.path.getsize(obj_path) if _size > obj_max_size: raise Exception(error_msg) @@ -232,7 +235,7 @@ def __call__(cls, obj_path, obj_max_size, pem_marker=None): frmt = "PEM" pem = _raw der_list = split_pem(pem) - der = b''.join(map(pem2der, der_list)) + der = b"".join(map(pem2der, der_list)) else: frmt = "DER" der = _raw @@ -251,12 +254,14 @@ def __call__(cls, obj_path, obj_max_size, pem_marker=None): # Public Keys # ############### + class _PubKeyFactory(_PKIObjMaker): """ Metaclass for PubKey creation. It casts the appropriate class on the fly, then fills in the appropriate attributes with import_from_asn1pkt() submethod. """ + def __call__(cls, key_path=None, cryptography_obj=None): # This allows to import cryptography objects directly if cryptography_obj is not None: @@ -326,11 +331,11 @@ class PubKey(metaclass=_PubKeyFactory): """ def verifyCert(self, cert): - """ Verifies either a Cert or an X509_Cert. """ + """Verifies either a Cert or an X509_Cert.""" h = cert.getSignatureHashName() tbsCert = cert.tbsCertificate sigVal = bytes(cert.signatureValue) - return self.verify(bytes(tbsCert), sigVal, h=h, t='pkcs') + return self.verify(bytes(tbsCert), sigVal, h=h, t="pkcs") @property def pem(self): @@ -378,6 +383,7 @@ class PubKeyRSA(PubKey, _EncryptAndVerifyRSA): Wrapper for RSA keys based on _EncryptAndVerifyRSA from crypto/pkcs1.py Use the 'key' attribute to access original object. """ + @crypto_validator def fill_and_store(self, modulus=None, modulusLen=None, pubExp=None): pubExp = pubExp or 65537 @@ -431,8 +437,7 @@ def encrypt(self, msg, t="pkcs", h="sha256", mgf=None, L=None): return _EncryptAndVerifyRSA.encrypt(self, msg, t=t, h=h, mgf=mgf, L=L) def verify(self, msg, sig, t="pkcs", h="sha256", mgf=None, L=None): - return _EncryptAndVerifyRSA.verify( - self, msg, sig, t=t, h=h, mgf=mgf, L=L) + return _EncryptAndVerifyRSA.verify(self, msg, sig, t=t, h=h, mgf=mgf, L=L) class PubKeyECDSA(PubKey): @@ -440,6 +445,7 @@ class PubKeyECDSA(PubKey): Wrapper for ECDSA keys based on the cryptography library. Use the 'key' attribute to access original object. """ + @crypto_validator def fill_and_store(self, curve=None): curve = curve or ec.SECP256R1 @@ -472,6 +478,7 @@ class PubKeyEdDSA(PubKey): Wrapper for EdDSA keys based on the cryptography library. Use the 'key' attribute to access original object. """ + @crypto_validator def fill_and_store(self, curve=None): curve = curve or x25519.X25519PrivateKey @@ -502,12 +509,14 @@ def verify(self, msg, sig, **kwargs): # Private Keys # ################ + class _PrivKeyFactory(_PKIObjMaker): """ Metaclass for PrivKey creation. It casts the appropriate class on the fly, then fills in the appropriate attributes with import_from_asn1pkt() submethod. """ + def __call__(cls, key_path=None, cryptography_obj=None): """ key_path may be the path to either: @@ -529,11 +538,14 @@ def __call__(cls, key_path=None, cryptography_obj=None): if cryptography_obj is not None: # We (stupidly) need to go through the whole import process because RSA # does more than just importing the cryptography objects... - obj = _PKIObj("DER", cryptography_obj.private_bytes( - encoding=serialization.Encoding.DER, - format=serialization.PrivateFormat.PKCS8, - encryption_algorithm=serialization.NoEncryption() - )) + obj = _PKIObj( + "DER", + cryptography_obj.private_bytes( + encoding=serialization.Encoding.DER, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ), + ) else: # Load from file obj = _PKIObjMaker.__call__(cls, key_path, _MAX_KEY_SIZE) @@ -575,8 +587,10 @@ def __call__(cls, key_path=None, cryptography_obj=None): class _Raw_ASN1_BIT_STRING(ASN1_BIT_STRING): """A ASN1_BIT_STRING that ignores BER encoding""" + def __bytes__(self): return self.val_readable + __str__ = __bytes__ @@ -603,7 +617,7 @@ def signTBSCert(self, tbsCert, h="sha256"): """ sigAlg = tbsCert.signature h = h or hash_by_oid[sigAlg.algorithm.val] - sigVal = self.sign(bytes(tbsCert), h=h, t='pkcs') + sigVal = self.sign(bytes(tbsCert), h=h, t="pkcs") c = X509_Cert() c.tbsCertificate = tbsCert c.signatureAlgorithm = sigAlg @@ -611,16 +625,16 @@ def signTBSCert(self, tbsCert, h="sha256"): return c def resignCert(self, cert): - """ Rewrite the signature of either a Cert or an X509_Cert. """ + """Rewrite the signature of either a Cert or an X509_Cert.""" return self.signTBSCert(cert.tbsCertificate, h=None) def verifyCert(self, cert): - """ Verifies either a Cert or an X509_Cert. """ + """Verifies either a Cert or an X509_Cert.""" tbsCert = cert.tbsCertificate sigAlg = tbsCert.signature h = hash_by_oid[sigAlg.algorithm.val] sigVal = bytes(cert.signatureValue) - return self.verify(bytes(tbsCert), sigVal, h=h, t='pkcs') + return self.verify(bytes(tbsCert), sigVal, h=h, t="pkcs") @property def pem(self): @@ -631,7 +645,7 @@ def der(self): return self.key.private_bytes( encoding=serialization.Encoding.DER, format=serialization.PrivateFormat.PKCS8, - encryption_algorithm=serialization.NoEncryption() + encryption_algorithm=serialization.NoEncryption(), ) def export(self, filename, fmt=None): @@ -655,7 +669,7 @@ def sign(self, data, h="sha256", **kwargs): Sign data. """ raise NotImplementedError - + @crypto_validator def verify(self, msg, sig, h="sha256", **kwargs): """ @@ -669,13 +683,30 @@ class PrivKeyRSA(PrivKey, _DecryptAndSignRSA): Wrapper for RSA keys based on _DecryptAndSignRSA from crypto/pkcs1.py Use the 'key' attribute to access original object. """ + @crypto_validator - def fill_and_store(self, modulus=None, modulusLen=None, pubExp=None, - prime1=None, prime2=None, coefficient=None, - exponent1=None, exponent2=None, privExp=None): + def fill_and_store( + self, + modulus=None, + modulusLen=None, + pubExp=None, + prime1=None, + prime2=None, + coefficient=None, + exponent1=None, + exponent2=None, + privExp=None, + ): pubExp = pubExp or 65537 - if None in [modulus, prime1, prime2, coefficient, privExp, - exponent1, exponent2]: + if None in [ + modulus, + prime1, + prime2, + coefficient, + privExp, + exponent1, + exponent2, + ]: # note that the library requires every parameter # in order to call RSAPrivateNumbers(...) # if one of these is missing, we generate a whole new key @@ -698,10 +729,15 @@ def fill_and_store(self, modulus=None, modulusLen=None, pubExp=None, if modulusLen and real_modulusLen != modulusLen: warning("modulus and modulusLen do not match!") pubNum = rsa.RSAPublicNumbers(n=modulus, e=pubExp) - privNum = rsa.RSAPrivateNumbers(p=prime1, q=prime2, - dmp1=exponent1, dmq1=exponent2, - iqmp=coefficient, d=privExp, - public_numbers=pubNum) + privNum = rsa.RSAPrivateNumbers( + p=prime1, + q=prime2, + dmp1=exponent1, + dmq1=exponent2, + iqmp=coefficient, + d=privExp, + public_numbers=pubNum, + ) self.key = privNum.private_key(default_backend()) pubkey = self.key.public_key() @@ -724,10 +760,16 @@ def import_from_asn1pkt(self, privkey): exponent1 = privkey.exponent1.val exponent2 = privkey.exponent2.val coefficient = privkey.coefficient.val - self.fill_and_store(modulus=modulus, pubExp=pubExp, - privExp=privExp, prime1=prime1, prime2=prime2, - exponent1=exponent1, exponent2=exponent2, - coefficient=coefficient) + self.fill_and_store( + modulus=modulus, + pubExp=pubExp, + privExp=privExp, + prime1=prime1, + prime2=prime2, + exponent1=exponent1, + exponent2=exponent2, + coefficient=coefficient, + ) def verify(self, msg, sig, t="pkcs", h="sha256", mgf=None, L=None): return self.pubkey.verify( @@ -748,6 +790,7 @@ class PrivKeyECDSA(PrivKey): Wrapper for ECDSA keys based on SigningKey from ecdsa library. Use the 'key' attribute to access original object. """ + @crypto_validator def fill_and_store(self, curve=None): curve = curve or ec.SECP256R1 @@ -757,8 +800,9 @@ def fill_and_store(self, curve=None): @crypto_validator def import_from_asn1pkt(self, privkey): - self.key = serialization.load_der_private_key(bytes(privkey), None, - backend=default_backend()) # noqa: E501 + self.key = serialization.load_der_private_key( + bytes(privkey), None, backend=default_backend() + ) # noqa: E501 self.pubkey = PubKeyECDSA(cryptography_obj=self.key.public_key()) self.marker = "EC PRIVATE KEY" @@ -776,6 +820,7 @@ class PrivKeyEdDSA(PrivKey): Wrapper for EdDSA keys Use the 'key' attribute to access original object. """ + @crypto_validator def fill_and_store(self, curve=None): curve = curve or x25519.X25519PrivateKey @@ -785,8 +830,9 @@ def fill_and_store(self, curve=None): @crypto_validator def import_from_asn1pkt(self, privkey): - self.key = serialization.load_der_private_key(bytes(privkey), None, - backend=default_backend()) # noqa: E501 + self.key = serialization.load_der_private_key( + bytes(privkey), None, backend=default_backend() + ) # noqa: E501 self.pubkey = PubKeyECDSA(cryptography_obj=self.key.public_key()) self.marker = "PRIVATE KEY" @@ -803,21 +849,25 @@ def sign(self, data, **kwargs): # Certificates # ################ + class _CertMaker(_PKIObjMaker): """ Metaclass for Cert creation. It is not necessary as it was for the keys, but we reuse the model instead of creating redundant constructors. """ + def __call__(cls, cert_path=None, cryptography_obj=None): # This allows to import cryptography objects directly if cryptography_obj is not None: - obj = _PKIObj("DER", cryptography_obj.public_bytes( - encoding=serialization.Encoding.DER, - )) + obj = _PKIObj( + "DER", + cryptography_obj.public_bytes( + encoding=serialization.Encoding.DER, + ), + ) else: # Load from file - obj = _PKIObjMaker.__call__(cls, cert_path, - _MAX_CERT_SIZE, "CERTIFICATE") + obj = _PKIObjMaker.__call__(cls, cert_path, _MAX_CERT_SIZE, "CERTIFICATE") obj.__class__ = Cert obj.marker = "CERTIFICATE" try: @@ -976,17 +1026,19 @@ def remainingDays(self, now=None): now = time.localtime() elif isinstance(now, str): try: - if '/' in now: - now = time.strptime(now, '%m/%d/%y') + if "/" in now: + now = time.strptime(now, "%m/%d/%y") else: - now = time.strptime(now, '%b %d %H:%M:%S %Y %Z') + now = time.strptime(now, "%b %d %H:%M:%S %Y %Z") except Exception: - warning("Bad time string provided, will use localtime() instead.") # noqa: E501 + warning( + "Bad time string provided, will use localtime() instead." + ) # noqa: E501 now = time.localtime() now = time.mktime(now) nft = time.mktime(self.notAfter) - diff = (nft - now) / (24. * 3600) + diff = (nft - now) / (24.0 * 3600) return diff def isRevoked(self, crl_list): @@ -1006,9 +1058,11 @@ def isRevoked(self, crl_list): Cert. Otherwise, the issuers are simply compared. """ for c in crl_list: - if (self.authorityKeyID is not None and - c.authorityKeyID is not None and - self.authorityKeyID == c.authorityKeyID): + if ( + self.authorityKeyID is not None + and c.authorityKeyID is not None + and self.authorityKeyID == c.authorityKeyID + ): return self.serial in (x[0] for x in c.revoked_cert_serials) elif self.issuer == c.issuer: return self.serial in (x[0] for x in c.revoked_cert_serials) @@ -1028,7 +1082,7 @@ def der(self): def __eq__(self, other): return self.der == other.der - + def __hash__(self): return hash(self.der) @@ -1054,18 +1108,23 @@ def show(self): print("Validity: %s to %s" % (self.notBefore_str, self.notAfter_str)) def __repr__(self): - return "[X.509 Cert. Subject:%s, Issuer:%s]" % (self.subject_str, self.issuer_str) # noqa: E501 + return "[X.509 Cert. Subject:%s, Issuer:%s]" % ( + self.subject_str, + self.issuer_str, + ) # noqa: E501 ################################ # Certificate Revocation Lists # ################################ + class _CRLMaker(_PKIObjMaker): """ Metaclass for CRL creation. It is not necessary as it was for the keys, but we reuse the model instead of creating redundant constructors. """ + def __call__(cls, cert_path): obj = _PKIObjMaker.__call__(cls, cert_path, _MAX_CRL_SIZE, "X509 CRL") obj.__class__ = CRL @@ -1167,6 +1226,7 @@ def show(self): # Certificate list # #################### + class CertList(list): """ An object that can store a list of Cert objects, load them and export them @@ -1183,8 +1243,9 @@ def __init__( # Parse the certificate list / CA if isinstance(certList, str): # It's a path. First get the _PKIObj - obj = _PKIObjMaker.__call__(CertList, certList, _MAX_CERT_SIZE, - "CERTIFICATE") + obj = _PKIObjMaker.__call__( + CertList, certList, _MAX_CERT_SIZE, "CERTIFICATE" + ) # Then parse the der until there's nothing left certList = [] @@ -1234,19 +1295,17 @@ def export(self, filename, fmt=None): @property def der(self): return b"".join(x.der for x in self) - + @property def pem(self): return "".join(x.pem for x in self) def __repr__(self): - return "" % ( - len(self), - ) + return "" % (len(self),) def show(self): for i, c in enumerate(self): - print(conf.color_theme.id(i, fmt="%04i"), end=' ') + print(conf.color_theme.id(i, fmt="%04i"), end=" ") print(repr(c)) @@ -1254,6 +1313,7 @@ def show(self): # Certificate chains # ###################### + class CertTree(CertList): """ An extension to CertList that additionally has a list of ROOT CAs @@ -1292,11 +1352,7 @@ def __init__( # Find the ROOT CAs if store isn't specified if not rootCAs: # Build cert store. - self.rootCAs = CertList([ - x - for x in certList - if x.isSelfSigned() - ]) + self.rootCAs = CertList([x for x in certList if x.isSelfSigned()]) # And remove those certs from the list for cert in self.rootCAs: certList.remove(cert) @@ -1315,10 +1371,7 @@ def tree(self): Get a tree-like object of the certificate list """ # We store the tree object as a dictionary that contains children. - tree = [ - (x, []) - for x in self.rootCAs - ] + tree = [(x, []) for x in self.rootCAs] # We'll empty this list eventually certList = list(self) @@ -1326,7 +1379,7 @@ def tree(self): # We make a list of certificates we have to search children for, and iterate # through it until it's emtpy. todo = list(tree) - + # Iterate while todo: cert, children = todo.pop() @@ -1344,6 +1397,7 @@ def getchain(self, cert): """ Return a chain of certificate that points from a ROOT CA to a certificate. """ + def _rec_getchain(chain, curtree): # See if an element of the current tree signs the cert, if so add it to # the chain, else recurse. @@ -1356,7 +1410,7 @@ def _rec_getchain(chain, curtree): if curchain: return curchain return None - + chain = _rec_getchain([], self.tree) if chain is not None: return CertTree(cert, chain) @@ -1375,6 +1429,7 @@ def show(self, ret: bool = False): """ Return the CertTree as a string certificate tree """ + def _rec_show(c, children, lvl=0): s = "" # Process the current CA @@ -1402,6 +1457,7 @@ def __repr__(self): len(self.rootCAs), ) + ####### # CMS # ####### @@ -1466,15 +1522,15 @@ def sign( attrType=ASN1_OID("contentType"), attrValues=[ eContentType, - ] + ], ), CMS_Attribute( attrType=ASN1_OID("messageDigest"), # "A message-digest attribute MUST have a single attribute value" attrValues=[ ASN1_STRING(hashed_message), - ] - ) + ], + ), ], signatureAlgorithm=cert.tbsCertificate.signature, ) @@ -1491,11 +1547,7 @@ def sign( # Build a chain of X509_Cert to ship (but skip the ROOT certificate) certTree = CertTree(cert, self.store) - certificates = [ - x.x509Cert - for x in certTree - if not x.isSelfSigned() - ] + certificates = [x.x509Cert for x in certTree if not x.isSelfSigned()] # Build final structure return CMS_ContentInfo( @@ -1511,27 +1563,21 @@ def sign( eContent=message, ), certificates=( - [ - CMS_CertificateChoices( - certificate=cert - ) - for cert in certificates - ] if certificates else None + [CMS_CertificateChoices(certificate=cert) for cert in certificates] + if certificates + else None ), crls=( - [ - CMS_RevocationInfoChoice( - crl=crl - ) - for crl in self.crls - ] if self.crls else None + [CMS_RevocationInfoChoice(crl=crl) for crl in self.crls] + if self.crls + else None ), signerInfos=[ signerInfo, ], - ) + ), ) - + def verify( self, contentInfo: CMS_ContentInfo, @@ -1550,10 +1596,7 @@ def verify( signeddata = contentInfo.content # Build the certificate chain - certificates = [ - Cert(x.certificate) - for x in signeddata.certificates - ] + certificates = [Cert(x.certificate) for x in signeddata.certificates] certTree = CertTree(certificates, self.store) # Check there's at least one signature @@ -1579,13 +1622,18 @@ def verify( ) if contentType != signeddata.encapContentInfo.eContentType: - raise ValueError("Inconsistent 'contentType' was detected in packet !") + raise ValueError( + "Inconsistent 'contentType' was detected in packet !" + ) if eContentType is not None and eContentType != contentType: - raise ValueError("Expected '%s' but got '%s' contentType !" % ( - eContentType, - contentType, - )) + raise ValueError( + "Expected '%s' but got '%s' contentType !" + % ( + eContentType, + contentType, + ) + ) except StopIteration: raise ValueError("Missing contentType in signedAttrs !") From 5ab0ae24ab5444b99ab39df3830a8521a78429e3 Mon Sep 17 00:00:00 2001 From: gpotter2 <10530980+gpotter2@users.noreply.github.com> Date: Wed, 26 Nov 2025 20:00:38 +0100 Subject: [PATCH 11/14] Cert.py: fix TreeChain tests --- scapy/layers/tls/cert.py | 13 +++++++- scapy/layers/x509.py | 1 - test/scapy/layers/tls/cert.uts | 61 +++++++++++++++++++++++----------- 3 files changed, 54 insertions(+), 21 deletions(-) diff --git a/scapy/layers/tls/cert.py b/scapy/layers/tls/cert.py index a8d99f27bc9..39a471bb68a 100644 --- a/scapy/layers/tls/cert.py +++ b/scapy/layers/tls/cert.py @@ -1357,7 +1357,12 @@ def __init__( for cert in self.rootCAs: certList.remove(cert) else: + # Store cert store. self.rootCAs = CertList(rootCAs) + # And remove those certs from the list if present (remove dups) + for cert in self.rootCAs: + if cert in certList: + certList.remove(cert) # Append our root CAs to the certList certList.extend(self.rootCAs) @@ -1403,9 +1408,15 @@ def _rec_getchain(chain, curtree): # the chain, else recurse. for c, subtree in curtree: curchain = chain + [c] + # If 'cert' is issued by c if cert.isIssuerCert(c): + # Final node of the chain ! + # (add the final cert if not self signed) + if c != cert: + curchain += [cert] return curchain else: + # Not the final node of the chain ! Recurse. curchain = _rec_getchain(curchain, subtree) if curchain: return curchain @@ -1413,7 +1424,7 @@ def _rec_getchain(chain, curtree): chain = _rec_getchain([], self.tree) if chain is not None: - return CertTree(cert, chain) + return CertTree(chain) else: return None diff --git a/scapy/layers/x509.py b/scapy/layers/x509.py index 5ca30babd0a..892af6941e7 100644 --- a/scapy/layers/x509.py +++ b/scapy/layers/x509.py @@ -14,7 +14,6 @@ from scapy.asn1.asn1 import ( ASN1_Codecs, ASN1_IA5_STRING, - ASN1_NULL, ASN1_OID, ASN1_PRINTABLE_STRING, ASN1_UTC_TIME, diff --git a/test/scapy/layers/tls/cert.uts b/test/scapy/layers/tls/cert.uts index ace1b75e1dc..237c4f9aeaa 100644 --- a/test/scapy/layers/tls/cert.uts +++ b/test/scapy/layers/tls/cert.uts @@ -614,30 +614,53 @@ pL/QlwVKvOoYKAKQvVR4CSFx09F9HdkWsKlhPdAKACL8x3vLCWRFCztAgfd9fDL1 mMpYjn0q7pBZc2T5NnReJaH1ZgUufzkVqSr7UIuOhWn0 -----END CERTIFICATE----- """) -c0.isIssuerCert(c1) and c1.isIssuerCert(c2) and not c0.isIssuerCert(c2) +assert c0.isIssuerCert(c1) and c1.isIssuerCert(c2) and not c0.isIssuerCert(c2) = Cert class : Checking isSelfSigned() -c2.isSelfSigned() and not c1.isSelfSigned() and not c0.isSelfSigned() +assert c2.isSelfSigned() and not c1.isSelfSigned() and not c0.isSelfSigned() = PubKey class : Checking verifyCert() -c2.pubKey.verifyCert(c2) and c1.pubKey.verifyCert(c0) +assert c2.pubKey.verifyCert(c2) and c1.pubKey.verifyCert(c0) + += CertTree class : Checking verification of chain +chain0 = CertTree([c0, c1, c2]).getchain(c0) +assert len(chain0) == 3 +assert chain0[0] == c1 +assert chain0[1] == c0 +assert chain0[2] == c2 +chain1 = CertTree([c2, c1, c0]).getchain(c1) +assert len(chain1) == 2 +assert chain1[0] == c1 +assert chain1[1] == c2 +chain2 = CertTree([c0, c2, c1]).getchain(c2) +assert len(chain2) == 1 +assert chain2[0] == c2 + += CertTree class : show() + +expected_repr = '/C=US/ST=Arizona/L=Scottsdale/O=Starfield Technologies, Inc./CN=Starfield Root Certificate Authority - G2 [Self Signed]\n /C=US/ST=Arizona/L=Scottsdale/O=Starfield Technologies, Inc./OU=http://certs.starfieldtech.com/repository//CN=Starfield Secure Certificate Authority - G2 [Not Self Signed]\n /OU=Domain Control Validated/CN=*.tools.ietf.org [Not Self Signed]\n' +assert CertTree([c0, c1, c2]).show(ret=True) == expected_repr + +repr_str = CertTree([], c0).show(ret=True) +assert repr_str == '/OU=Domain Control Validated/CN=*.tools.ietf.org [Not Self Signed]\n' + += CertTree class : verify + +CertTree([c1, c2]).verify(c0) +CertTree([c2]).verify(c1) + +try: + CertTree([c1]).verify(c0) + assert False +except ValueError: + pass + +try: + CertTree([c2]).verify(c0) + assert False +except ValueError: + pass -= Chain class : Checking chain construction -assert len(Chain([c0, c1, c2])) == 3 -assert len(Chain([c0], c1)) == 2 -len(Chain([c0], c2)) == 1 - -= Chain class : repr - -expected_repr = """__ /C=US/ST=Arizona/L=Scottsdale/O=Starfield Technologies, Inc./CN=Starfield Root Certificate Authority - G2 [Self Signed] - _ /C=US/ST=Arizona/L=Scottsdale/O=Starfield Technologies, Inc./OU=http://certs.starfieldtech.com/repository//CN=Starfield Secure Certificate Authority - G2 - _ /OU=Domain Control Validated/CN=*.tools.ietf.org""" -assert str(Chain([c0, c1, c2])) == expected_repr - -= Test __repr__ - -repr_str = Chain([], c0).__repr__() -assert repr_str == '__ /OU=Domain Control Validated/CN=*.tools.ietf.org [Not Self Signed]\n' = Test GeneralizedTime From 8fff3d951af19267409247abc2431cfc67f1b589 Mon Sep 17 00:00:00 2001 From: gpotter2 <10530980+gpotter2@users.noreply.github.com> Date: Mon, 1 Dec 2025 15:41:08 +0100 Subject: [PATCH 12/14] Add missing NETLOGON flags --- scapy/layers/smb.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/scapy/layers/smb.py b/scapy/layers/smb.py index 676021e1d6b..115de5f7473 100644 --- a/scapy/layers/smb.py +++ b/scapy/layers/smb.py @@ -1001,10 +1001,11 @@ class NETLOGON_SAM_LOGON_RESPONSE_NT40(NETLOGON): 0x00000800: "SELECT_SECRET_DOMAIN_6", 0x00001000: "FULL_SECRET_DOMAIN_6", 0x00002000: "WS", - 0x00004000: "DS_8", - 0x00008000: "DS_9", - 0x00010000: "DS_10", # guess - 0x00020000: "DS_11", # guess + 0x00004000: "DS_8", # >=2008R2 + 0x00008000: "DS_9", # >=2012 + 0x00010000: "DS_10", # >=2016 + 0x00020000: "DS_11", # >=2019 + 0x00040000: "DS_12", # >=2025 0x20000000: "DNS_CONTROLLER", 0x40000000: "DNS_DOMAIN", 0x80000000: "DNS_FOREST", From 144df4e8f780da79a0846dbc028534ae90cbd0c4 Mon Sep 17 00:00:00 2001 From: gpotter2 <10530980+gpotter2@users.noreply.github.com> Date: Mon, 1 Dec 2025 15:41:17 +0100 Subject: [PATCH 13/14] SPNEGO: support reading KRB5CCNAME --- scapy/layers/spnego.py | 49 +++++++++++++++++++++++++++++++++------ scapy/modules/ticketer.py | 29 ++++++++++++++++++----- 2 files changed, 65 insertions(+), 13 deletions(-) diff --git a/scapy/layers/spnego.py b/scapy/layers/spnego.py index aebebe88293..2ce9500b045 100644 --- a/scapy/layers/spnego.py +++ b/scapy/layers/spnego.py @@ -16,6 +16,7 @@ `GSSAPI `_ """ +import os import struct from uuid import UUID @@ -640,9 +641,11 @@ def from_cli_arguments( HashAes128Sha96: bytes = None, kerberos_required: bool = False, ST=None, + TGT=None, KEY=None, ccache: str = None, debug: int = 0, + use_krb5ccname: bool = False, ): """ Initialize a SPNEGOSSP from a list of many arguments. @@ -656,9 +659,12 @@ def from_cli_arguments( :param HashAes256Sha96: (bytes) if provided, used for auth (Kerberos) :param HashAes128Sha96: (bytes) if provided, used for auth (Kerberos) :param ST: if provided, the service ticket to use (Kerberos) + :param TGT: if provided, the TGT to use (Kerberos) :param KEY: if ST provided, the session key associated to the ticket (Kerberos). - Else, the user secret key. + This can be either for the ST or TGT. Else, the user secret key. :param ccache: (str) if provided, a path to a CCACHE (Kerberos) + :param use_krb5ccname: (bool) if true, the KRB5CCNAME environment variable will + be used if available. """ kerberos = True hostname = None @@ -682,6 +688,10 @@ def from_cli_arguments( # not a UPN: NTLM only kerberos = False + # If we're asked, check the environment for KRB5CCNAME + if use_krb5ccname and ccache is None and "KRB5CCNAME" in os.environ: + ccache = os.environ["KRB5CCNAME"] + # Do we need to ask the password? if all( x is None @@ -691,6 +701,7 @@ def from_cli_arguments( HashNt, HashAes256Sha96, HashAes128Sha96, + ccache, ] ): # yes. @@ -702,7 +713,7 @@ def from_cli_arguments( # Kerberos if kerberos and hostname: # Get ticket if we don't already have one. - if ST is None and ccache is not None: + if ST is None and TGT is None and ccache is not None: # In this case, load the KerberosSSP from ccache from scapy.modules.ticketer import Ticketer @@ -710,11 +721,34 @@ def from_cli_arguments( t = Ticketer() t.open_ccache(ccache) - # Look for the ticketer that we'll use - raise NotImplementedError - - ssps.append(t.ssp()) - elif ST is None: + # Look for the ticket that we'll use. We chose: + # - either a ST if the SPN matches our target + # - else a TGT if we got nothing better + tgts = [] + for i, (tkt, key, upn, spn) in enumerate(t.iter_tickets()): + # Check that it's for the correct user + if upn.lower() == UPN.lower(): + # Check that it's either a TGT or a ST to the correct service + if spn.lower().startswith("krbtgt/"): + # TGT. Keep it, and see if we don't have a better ST. + tgts.append(t.ssp(i)) + elif hostname in spn: + # ST. We're done ! + ssps.append(t.ssp(i)) + break + else: + # No ST found + if tgts: + # Using a TGT ! + ssps.append(tgts[0]) + else: + # Nothing found + t.show() + raise ValueError( + f"Could not find a ticket for {upn}, either a " + f"TGT or towards {hostname}" + ) + elif ST is None and TGT is None: # In this case, KEY is supposed to be the user's key. from scapy.libs.rfc3961 import Key, EncryptionType @@ -748,6 +782,7 @@ def from_cli_arguments( KerberosSSP( UPN=UPN, ST=ST, + TGT=TGT, KEY=KEY, debug=debug, ) diff --git a/scapy/modules/ticketer.py b/scapy/modules/ticketer.py index 78f1c7e234d..9d5a45b821f 100644 --- a/scapy/modules/ticketer.py +++ b/scapy/modules/ticketer.py @@ -859,12 +859,22 @@ def ssp(self, i): """ if isinstance(i, int): ticket, sessionkey, upn, spn = self.export_krb(i) - return KerberosSSP( - ST=ticket, - KEY=sessionkey, - UPN=upn, - SPN=spn, - ) + if spn.startswith("krbtgt/"): + # It's a TGT + return KerberosSSP( + TGT=ticket, + KEY=sessionkey, + UPN=upn, + SPN=None, # Use target_name only + ) + else: + # It's a ST + return KerberosSSP( + ST=ticket, + KEY=sessionkey, + UPN=upn, + SPN=spn, + ) elif isinstance(i, str): spn = i key = self.get_cred(spn) @@ -2576,3 +2586,10 @@ def renew(self, i, ip=None, additional_tickets=[], **kwargs): return self.import_krb(res, _inplace=i) + + def iter_tickets(self): + """ + Iterate through the tickets in the ccache + """ + for i in range(len(self.ccache.credentials)): + yield self.export_krb(i) From 17199717c191825e8addbb737e60a02c41657580 Mon Sep 17 00:00:00 2001 From: gpotter2 <10530980+gpotter2@users.noreply.github.com> Date: Mon, 1 Dec 2025 21:11:39 +0100 Subject: [PATCH 14/14] SPNEGO: add tests & fix bugs --- scapy/layers/spnego.py | 13 ++- scapy/libs/rfc3961.py | 2 +- test/scapy/layers/spnego.uts | 193 +++++++++++++++++++++++++++++++++++ 3 files changed, 203 insertions(+), 5 deletions(-) create mode 100644 test/scapy/layers/spnego.uts diff --git a/scapy/layers/spnego.py b/scapy/layers/spnego.py index 2ce9500b045..9cbd85acec9 100644 --- a/scapy/layers/spnego.py +++ b/scapy/layers/spnego.py @@ -83,6 +83,7 @@ from scapy.layers.kerberos import ( Kerberos, KerberosSSP, + _parse_spn, _parse_upn, ) from scapy.layers.ntlm import ( @@ -672,11 +673,9 @@ def from_cli_arguments( if ":" in target: if not valid_ip6(target): hostname = target - target = str(Net6(target)) else: if not valid_ip(target): hostname = target - target = str(Net(target)) # Check UPN try: @@ -726,13 +725,15 @@ def from_cli_arguments( # - else a TGT if we got nothing better tgts = [] for i, (tkt, key, upn, spn) in enumerate(t.iter_tickets()): + spn, _ = _parse_spn(spn) + spn_host = spn.split("/")[-1] # Check that it's for the correct user if upn.lower() == UPN.lower(): # Check that it's either a TGT or a ST to the correct service if spn.lower().startswith("krbtgt/"): # TGT. Keep it, and see if we don't have a better ST. tgts.append(t.ssp(i)) - elif hostname in spn: + elif hostname.lower() == spn_host.lower(): # ST. We're done ! ssps.append(t.ssp(i)) break @@ -797,7 +798,11 @@ def from_cli_arguments( if not kerberos_required: if HashNt is None and password is not None: HashNt = MD4le(password) - ssps.append(NTLMSSP(UPN=UPN, HASHNT=HashNt)) + if HashNt is not None: + ssps.append(NTLMSSP(UPN=UPN, HASHNT=HashNt)) + + if not ssps: + raise ValueError("Unexpected case ! Please report.") # Build the SSP return cls(ssps) diff --git a/scapy/libs/rfc3961.py b/scapy/libs/rfc3961.py index bc7e2e8aee5..e07d00c1df9 100644 --- a/scapy/libs/rfc3961.py +++ b/scapy/libs/rfc3961.py @@ -1451,7 +1451,7 @@ def prfplus(key, pepper): # RFC 4556 # ############ -def octetstring2key(etype: EncryptionType, x: bytes) -> bytes: +def octetstring2key(etype: EncryptionType, x: bytes) -> Key: """ RFC4556 octetstring2key:: diff --git a/test/scapy/layers/spnego.uts b/test/scapy/layers/spnego.uts new file mode 100644 index 00000000000..46844b59d49 --- /dev/null +++ b/test/scapy/layers/spnego.uts @@ -0,0 +1,193 @@ +% SPNEGO unit tests + ++ Special SPNEGO tests + += SPNEGOSSP.from_cli_arguments - Utils + +from unittest import mock + +NTLM = '1.3.6.1.4.1.311.2.2.10' +KERBEROS = '1.2.840.113554.1.2.2' + +# Detect password prompts +def password_failure(*args, **kwargs): + raise ValueError("Password was prompted unexpectedly !") + +def password_input(*args, **kwargs): + return "Password" + + +def test_pwfail(**kwargs): + """Password means failure""" + with mock.patch('prompt_toolkit.prompt', side_effect=password_failure): + return SPNEGOSSP.from_cli_arguments(**kwargs) + + +def test_pwinput(**kwargs): + """Password is entered""" + with mock.patch('prompt_toolkit.prompt', side_effect=password_input): + return SPNEGOSSP.from_cli_arguments(**kwargs) + += SPNEGOSSP.from_cli_arguments - Username + Password - With input + +ssp = test_pwinput( + UPN="Administrator", + target="machine.domain.local", +) +assert isinstance(ssp, SPNEGOSSP) +assert len(ssp.supported_ssps) == 1 +assert ssp.supported_ssps[NTLM].HASHNT == b'\xa4\xf4\x9c@e\x10\xbd\xca\xb6\x82N\xe7\xc3\x0f\xd8R' + += SPNEGOSSP.from_cli_arguments - Username + Password - With prompt + +try: + test_pwfail( + UPN="Administrator", + target="machine.domain.local", + ) + assert False, "Should have prompted for password !" +except ValueError: + pass + += SPNEGOSSP.from_cli_arguments - Username + Password - No input + +ssp = test_pwfail( + UPN="Administrator", + target="machine.domain.local", + password="Password", +) +assert isinstance(ssp, SPNEGOSSP) +assert len(ssp.supported_ssps) == 1 +assert ssp.supported_ssps[NTLM].HASHNT == b'\xa4\xf4\x9c@e\x10\xbd\xca\xb6\x82N\xe7\xc3\x0f\xd8R' + += SPNEGOSSP.from_cli_arguments - UPN + Password - With input + +ssp = test_pwinput( + UPN="Administrator@domain.local", + target="machine.domain.local", +) +assert isinstance(ssp, SPNEGOSSP) +assert len(ssp.supported_ssps) == 3 +assert ssp.supported_ssps[NTLM].HASHNT == b'\xa4\xf4\x9c@e\x10\xbd\xca\xb6\x82N\xe7\xc3\x0f\xd8R' +assert ssp.supported_ssps[KERBEROS].UPN == "Administrator@domain.local" + += SPNEGOSSP.from_cli_arguments - UPN + CCache - Prepare + +import os, base64 +from scapy.utils import get_temp_file + +# Create CCACHE +DATA = """ +BQQAAAAAAAAAAAAAAAAAAAAAAAEAAAABAAAADERPTUFJTi5MT0NBTAAAAA1BZG1pbmlzdHJhdG9y +AAAAAgAAAAIAAAAMRE9NQUlOLkxPQ0FMAAAABmtyYnRndAAAAAxET01BSU4uTE9DQUwAEgAAACAb +BwocJhrPafZNOEpgJ0Ex7+bIGgYmV1xIOINqhSFV12ktpDBpLaQwaS4wy2kuMMsAQOEAAAAAAAAA +AAAAAAAE2GGCBNQwggTQoAMCAQWhDhsMRE9NQUlOLkxPQ0FMoiEwH6ADAgECoRgwFhsGa3JidGd0 +GwxET01BSU4uTE9DQUyjggSUMIIEkKADAgESoQMCAQKiggSCBIIEfhztXzlAS96FcY2W1vT3dfYk +skGMQuNRwWGyCKReTQQoSNuN+HXmtGgTlEAtf/L0QS5TCAzJKKbnvK6uNw19q/fYd/PJJMbOibmO +Ga1AWrt66Unrcq+AS/iMNgWYtW1qk+Kz7GmkwP/+seilbgZVZPK1JVg0m5oAQn8k8l53Sq6dPvDX +SB7eGtE0UzAM5a5CrpdKALtgbpkjSX2Y8QGmNEC3fVag2k7NP8ZHLd6qLoAmuUDB660vFFIXloRw +RZUe+wpeKX/d3pwcUyJiH0KJlEtPLldgo3EmBo9bUSzxul1MZ6s4oJNWX6MCOVwuTpDnJakBlmH5 +XAFGtxi0Ip7hGpgh4E8AOuhzEJhKaZK4VofcZQAU3KiGq1uOv/4Ema+TxXL83lbdpHX2T3D6naZZ +LOom6cOyMaYzWLs7UGmXtKKubIC5ePlCeV/lrFrEX0zOc86rxdEPw7DXvn4RfukTSjW74+9uiQYv +foqZTB6RIa+OmBg5SOWnceTnwC9P78jNLS5guOjOgBZ0xAMYeXydNloVW3h+XyngNdxiT3qCO+II +rl4uB9ugCQnod1PsvU6cJ6t1OfvhsB+6hXkoloA+RpssC/aMyzWE5985xSBoc91j4P4U6ZJWaCdr +3CaquJVVvIEgAQchlf6aWLI71CYCM+T9dXuzXTbtap7tsYq8/9hWBNs7rwIb7Mok0Zrn74WyU1tB +0fHXLIJqk4wEK4+Kp1w+vSvjULyXhhX1T9IGoTHXKUaXFc5MmLxG9P0jwA4VhrKI6thxK5MRN7gK +xw1OkGDzISTLtr6J4Po6b5ghI4hbxk7AA6y0PwN7DHhIl9OiZPqMcvv5byX6sUc0OSGaFGa0A1uz +/sdsYopfnD0zKBaWXBo9B8MHQ1RQnYjydwCJ78J0few83ZBE8vcb52ngkeIppaEnRuiMCZd0+bsv +X19xsbIXnq08jxrzdn2aqLuWQxHMr/sddfbe5blmGS1JFuwms/m45Ha1T3wK65Efcm6Xtn7qWZOh +GDmptGmM93V/tXpbTEfD18EchMDGxx+LMDOa1nCzOeTXeyEfg4sJp6oOc2+8K7GbwPWdjIomp95R +m/OcgN3DThRC7uELcpLcep5hAdqrPvKYovZeiYsPLl0mdyJ2dWjcOaPg+S3m/T5BOsNSVF4yEWEc +kE7Ahy5QDvag0UFs9vGjkdeKTXk00fQTBCMNLQSO42afxJOoOaYN8gJu81cut1h4ZJm9RngDI+8C +Q+1Yxf9eP/PChFVaL6WL2nsZOqdDjJ4/19qqBK9eDgMzaOqggR91i9m7Tb4AYvb8LnyKh+UE0VBC +lfUM3RD2MA65+OZaEvVDfsWMNdJS1QY9LaW39Dh5n6gV76YmAv0zc1qHux0Z2mOASr3d2aezAFpo +rhcKMZz5YuxbWTB559eoGZNGjRi1gmjVRVTe+mt92Ww8u1eDXV64aH4zc5n7uZpqsWnyRz8K2jjE +slXWBjQr9vLT3ChFnSuH9qKhE+W7vTcdy3k1VuMHL6831nqB17sXR/cZYt0Ajc+L71oAAAAAAAAA +AQAAAAEAAAAMRE9NQUlOLkxPQ0FMAAAADUFkbWluaXN0cmF0b3IAAAADAAAAAgAAAAxET01BSU4u +TE9DQUwAAAAEY2lmcwAAABBEQzEuRE9NQUlOLkxPQ0FMABIAAAAgxahEIPO0srYHJe89OfcWetLT +G6WLKdDHKMTn0+wtykZpLaQwaS2kPGkuMMtpLjDLAEClAAAAAAAAAAAAAAAABPphggT2MIIE8qAD +AgEFoQ4bDERPTUFJTi5MT0NBTKIjMCGgAwIBA6EaMBgbBGNpZnMbEERDMS5ET01BSU4uTE9DQUyj +ggS0MIIEsKADAgESoQMCAQOiggSiBIIEnragYfz/CVtO/WA8R5S6DwhWbd1cxVKg7KnLMrqqbcwx +3USZktAVxuPeLpoUMDLfs5D5ADUo4jHlLJrEAbGsWdFj7DgMYIHIWftRNIvGcCQqjG3/gvL/16+C +GU6ghCUuVKpq16J2KRiHf97QnCAL79PK2d52L+k+f106GI+pRqWlpvrDEHd4Xtve/OW37sXRM3ar +NYUfwjR4uVK7FzHWzisKb8DjgoqZJHt83LVh7Zk2Qxc6p0PMThwWLEI7RB9l8ll30C5cq1qH5kvh +olIipAuAFxNniqE6UZl5GByGg9ck7KDrVrtz9p111BiCxnspfGdPuswjakiSNViSmCV7IsqH16gd +9Z9VBlNNU//mLJd93qsdSxbLclY6F7D7TCAbyv4fgMrDeQ6GVqgjEDG8xtp7T5LUMZPwSgM0pVol +kAWwSbmUh8i4OXQIzI0EAv2aNi0BsCWg1sb9Ri0NVQT5wSaFGHVpinxqrNVd5/mC2a4QgeQ2fOx9 +3fJmShdsrVjVPfcqvedk0L1xw0992l1K18KmtPFu7BhgfkJPOR+FfHJa2zPfnIGsbvuC282vBCbD +krDOug/Uqn01WUmUiwwGBWSTWOOfVDBFy6ETxXJvIkwV8n6Q1wMi8LgcBKc4LdHjbEqc8xJ8yvhA +YJ00xOQNkCu/XK6R4gV5ZkhMs3tB7FoKYbizyAKSuhow3f8Bej/+Lp4VH6gqY33us3jImFizDPmG +lcOrvTl2l0l8ZnQwpT/qP46yD34EIIvujZImf+gFv27F6SFhPkUmi0xISRCJU7XwYdZjNNhnsuom +lGeBvDYhGQtJZ44ZXM7cRggQ+46y60KsHhZHucx5fIzrWrTWUur/gyzf4/ExB3YHX8k4WqzLbt0H +t31LviTZf2a1A2ODwZTp2K8Q506qwr/e+wDRr+uNBOBo04c/tlpvSdi+lrbZODNMHGVIkuCo01Ei +r68jRWaqmTrasXC5tmWyXiH3egN1BkUXqieXNBWYowTc7qr+820TbsOkMTPrxJje0cbvppT3NmB7 +EwyldUoxKDbrtOVr1VvnQWB8IHA2UwRDeuiHP2lRUGHyAHYDH2tlcpGhpk5jqrh4ok93mzZQ1EUz +qbc9tNIRFJCGJlRnf8F5Vy1Xr7o/RfiVooOFXLktC8COr+lwccV1xQfhKEDLOgvqvVHjaQAvlp5v +3Ce5973nwaQ3ttJakXXX5xk94Jzr9JeP/WIoVVHAnl661Zpd01KHIh8Belk+q2xRbJYKLRVmaoG3 +jZmMYkEyP0W0KF3BBFMwRSXJkmyCojpebxKUPBeLelD+l7f2LY/limNhq3F/yju3HAGnuKRPybOu +haMfIiGCaH3FgEqFrudK+KQq4T5CZT/PoGsdmIK+WCElYahwGM6tueVa4RHhBHlSbi0Uyx7KexjL +UHk7A8VRQvSMuQ0S6mj3rOp2w03ZeN+eHcj02cECUx0Sv2MQ5ds5o839X3Z/NsdquJ+83gx7SEHo +7ziAcW28wWcCS1m+eRtxJA2rHILASEwsJbhXQVmllqRY3IuYGztLbKpPKUzveq/2JVBHYZPgKb56 +UJ8RjD9bppHbawAAAAA= +""" +ccache_file = get_temp_file() +with open(ccache_file, "wb") as fd: + fd.write(base64.b64decode(DATA.strip())) + +os.environ["KRB5CCNAME"] = ccache_file + += SPNEGOSSP.from_cli_arguments - UPN + CCache - TGT from KRB5CCNAME + +ssp = test_pwfail( + UPN="Administrator@domain.local", + target="machine.domain.local", + use_krb5ccname=True, +) +assert len(ssp.supported_ssps) == 2 +assert ssp.supported_ssps[KERBEROS].TGT +assert not ssp.supported_ssps[KERBEROS].ST + += SPNEGOSSP.from_cli_arguments - UPN + CCache - TGT from ccache + +ssp = test_pwfail( + UPN="Administrator@domain.local", + target="machine.domain.local", + ccache=ccache_file +) +assert len(ssp.supported_ssps) == 2 +assert ssp.supported_ssps[KERBEROS].TGT +assert not ssp.supported_ssps[KERBEROS].ST + += SPNEGOSSP.from_cli_arguments - UPN + CCache - ST from ccache + +ssp = test_pwfail( + UPN="Administrator@domain.local", + target="dc1.domain.local", + ccache=ccache_file +) +assert len(ssp.supported_ssps) == 2 +assert ssp.supported_ssps[KERBEROS].ST +assert not ssp.supported_ssps[KERBEROS].TGT + += SPNEGOSSP.from_cli_arguments - UPN + CCache - Failure + +try: + test_pwfail( + UPN="Administrator@domain.local", + target="machine.domain.local", + ) + assert False, "Should have prompted for password !" +except ValueError: + pass + += SPNEGOSSP.from_cli_arguments - UPN + CCache - Bad UPN + +try: + test_pwfail( + UPN="toto@domain.local", + target="machine.domain.local", + ccache=ccache_file + ) + assert False, "Should have failed !" +except ValueError: + pass