本题是 CISCN 2025 的一道密码学题目,涉及椭圆曲线数字签名算法(ECDSA)的安全性。题目提供了三个文件:
task.py- 签名生成程序的源代码
public.pem- ECDSA 公钥文件
signatures.txt- 60 个消息及其对应的签名
题目要求通过分析这些文件,恢复私钥,并计算 flag 格式为flag{MD5(str(private_key))}。
在开始分析之前,我们需要了解 ECDSA 的基本原理,这样才能理解题目中的漏洞所在。
ECDSA (Elliptic Curve Digital Signature Algorithm) 是一种基于椭圆曲线密码学的数字签名算法。它被广泛应用于:
区块链技术(如比特币、以太坊的交易签名)
SSL/TLS 证书
SSH 密钥认证
代码签名
相比传统的 RSA 算法,ECDSA 在提供相同安全强度的情况下,使用更短的密钥长度,因此在性能和存储空间上更具优势。
正确的 ECDSA 密钥生成应该遵循以下步骤:
选择椭圆曲线: 选择一条标准的椭圆曲线,如 NIST P-256、NIST P-384 或 NIST P-521
生成私钥: 使用密码学安全的随机数生成器(CSRNG)生成私钥 d
约束条件: 私钥必须满足 1 ≤ d < n,其中 n 是椭圆曲线的阶(order)
计算公钥: 通过椭圆曲线点乘运算计算公钥 Q = d × G,其中 G 是曲线的基点(generator)
关键安全要求: 私钥 d 必须是真正的随机数,不能被任何人预测或重现。这是整个 ECDSA 系统安全性的基础。
ECDSA 的安全性建立在椭圆曲线离散对数问题(ECDLP)的困难性上:
给定公钥 Q 和基点 G,在计算上无法找到私钥 d,使得 Q = d × G
但是,这个安全性假设有一个前提条件:攻击者必须通过解决 ECDLP 来获取私钥。如果私钥生成过程本身存在漏洞,攻击者可以完全绕过这个数学难题。
现在让我们仔细审计task.py的源代码,寻找可能存在的安全漏洞。
打开task.py文件,我们看到以下私钥生成代码:
digest_int = int.from_bytes(sha512(b"Welcome to this challenge!").digest(), "big")
curve_order = NIST521p.order
priv_int = digest_int % curve_order
priv_bytes = long_to_bytes(priv_int, 66)
sk = SigningKey.from_string(priv_bytes, curve=NIST521p)
让我们逐行分析这段代码的含义:
第 1 行: 计算固定字符串的哈希
digest_int = int.from_bytes(sha512(b"Welcome to this challenge!").digest(), "big")
对固定字符串"Welcome to this challenge!"计算 SHA-512 哈希
SHA-512 产生 512 位(64 字节)的哈希值
使用大端序(big-endian)将字节串转换为整数
第 2-3 行: 计算私钥
curve_order = NIST521p.order
priv_int = digest_int % curve_order
获取 NIST P-521 曲线的阶(一个固定的大素数)
对曲线阶取模,确保私钥在有效范围内
第 4-5 行: 创建密钥对象
priv_bytes = long_to_bytes(priv_int, 66)
sk = SigningKey.from_string(priv_bytes, curve=NIST521p)
将私钥整数转换为 66 字节的字节串
创建 ECDSA 签名密钥对象
通过代码审计,我们发现了一个致命的安全漏洞:
漏洞类型: 可预测的私钥生成(Predictable Private Key Generation)
漏洞详情:
私钥不是通过密码学安全的随机数生成器产生的
而是使用固定的明文字符串"Welcome to this challenge!"经过确定性的哈希运算得到
任何人只要知道这个字符串,都可以重现相同的私钥
为什么这是严重漏洞:
在密码学中,私钥的安全性完全依赖于其不可预测性。这就像:
安全的做法: 用随机数生成器创建一个完全随机的密码
错误的做法: 用你的生日作为密码(虽然是个数字,但可以被猜测)
本题的问题在于,私钥生成过程是完全确定性的:
输入相同(固定字符串)
算法相同(SHA-512)
输出必然相同(私钥)
而且,这个固定字符串直接写在源代码中,完全公开。
题目使用了 NIST P-521 曲线,这是一条标准的椭圆曲线:
安全强度: 提供约 256 位的安全性
密钥长度: 521 位(注意不是 512 位)
阶的大小: 约 2^521,是一个 521 位的素数
曲线本身是安全的,没有已知的数学漏洞。问题不在于曲线的选择,而在于私钥生成方式违背了密码学的基本原则。
task.py中还有 nonce(随机数)生成代码:
def nonce(i):
seed = sha512(b"bias" + bytes([i])).digest()
k = int.from_bytes(seed, "big")
return k
这段代码也存在同样的问题:
Nonce 也是确定性生成的,而非真正的随机数
对于相同的索引 i,总是产生相同的 nonce
在 ECDSA 中,如果 nonce 被重用或可预测,攻击者可以通过分析多个签名来恢复私钥。这也是一个严重的安全漏洞,但由于私钥生成漏洞过于明显,我们不需要通过签名分析来恢复私钥。
既然私钥生成过程是确定性的,我们可以直接重现这个过程来获取私钥。
攻击步骤非常简单:
重现私钥生成: 执行与task.py完全相同的私钥生成代码
计算对应公钥: 使用恢复的私钥生成对应的公钥
验证正确性: 将生成的公钥与题目提供的public.pem对比
计算 flag: 按照题目要求计算 flag
这个攻击之所以能够成功,是因为整个过程具有以下特性:
确定性:
SHA-512 是确定性哈希函数,相同输入必然产生相同输出
取模运算也是确定性的数学运算
椭圆曲线点乘运算同样是确定性的
公开性:
字符串"Welcome to this challenge!"直接写在源代码中
NIST P-521 是公开的标准曲线,参数完全公开
所有的算法和流程都是公开的
可重现性:
整个过程不涉及任何随机数生成
任何人都可以在任何时间、任何地点重现这个过程
结果完全一致
让我们从数学角度理解这个过程:
正常的 ECDSA 私钥恢复问题:
已知: 公钥 Q、基点 G、曲线参数
未知: 私钥 d
问题: 从 Q = d × G 求解 d (椭圆曲线离散对数问题,困难)
本题的情况:
已知: 生成私钥的完整代码和所有参数
未知: 无
问题: 直接执行代码即可得到私钥(简单的代码执行,轻松)
这完全绕过了椭圆曲线的数学安全性。
现在我们开始编写 Python 脚本来恢复私钥。
from ecdsa import SigningKey, NIST521p, VerifyingKey
from hashlib import sha512, md5
from Crypto.Util.number import long_to_bytes
库说明:
ecdsa: Python 的 ECDSA 实现库,提供签名密钥和验证密钥的操作
hashlib: Python 标准库,提供 SHA-512 和 MD5 哈希函数
Crypto.Util.number: PyCryptodome 库,提供long_to_bytes等数字转换工具
# 步骤1: 计算 SHA-512 哈希
digest_int = int.from_bytes(sha512(b"Welcome to this challenge!").digest(), "big")
# 步骤2: 获取 NIST P-521 曲线的阶
curve_order = NIST521p.order
# 步骤3: 计算私钥
priv_int = digest_int % curve_order
详细解释:
步骤 1: 计算哈希值并转换为整数
sha512(b"Welcome to this challenge!").digest()
这会产生 64 字节(512 位)的二进制数据。例如:
b'\x1a\x5e...' (64 bytes)
int.from_bytes(..., "big")
将这 64 字节按大端序转换为一个巨大的整数。大端序意味着最高有效字节在前。
步骤 2: 获取曲线阶
curve_order = NIST521p.order
NIST P-521 曲线的阶是一个固定的 521 位素数:
6864797660130609714981900799081393217269435300143305409394463459185543183397655394245057746333217197532963996371363321113864768612440380340372808892707005449
步骤 3: 取模运算
priv_int = digest_int % curve_order
这确保私钥在有效范围 [0, n-1] 内。由于 SHA-512 产生 512 位,而曲线阶是 521 位,所以哈希值小于曲线阶,取模运算实际上不会改变这个值,但这是标准的安全实践。
# 步骤4: 将私钥转换为字节格式
priv_bytes = long_to_bytes(priv_int, 66)
# 步骤5: 创建签名密钥对象
sk = SigningKey.from_string(priv_bytes, curve=NIST521p)
# 步骤6: 获取对应的验证密钥(公钥)
vk_computed = sk.verifying_key
为什么是 66 字节:
NIST P-521 曲线的私钥需要 521 位:
521 位 ÷ 8 = 65.125 字节
向上取整到 66 字节(528 位)
在存储时,会在高位补零使其达到 66 字节。
密钥对象创建:
SigningKey.from_string()从字节串创建私钥对象
sk.verifying_key自动计算对应的公钥
公钥通过椭圆曲线点乘运算得到: Q = d × G
# 读取题目提供的公钥
with open("public.pem", "rb") as f:
vk_file = VerifyingKey.from_pem(f.read())
# 对比两个公钥
if vk_computed.to_string() == vk_file.to_string():
print("[+] 公钥匹配! 私钥恢复成功!")
else:
print("[-] 公钥不匹配,攻击失败")
验证原理:
由于椭圆曲线密码学的数学特性:
每个私钥对应唯一的公钥
公钥 = 私钥 × 基点(点乘运算)
这个映射关系是确定性的
因此,如果我们恢复的私钥是正确的,那么由它生成的公钥必然与题目提供的公钥完全一致。这一步验证了我们的攻击假设是正确的。
PEM 格式说明:
public.pem文件使用 PEM 格式存储公钥:
-----BEGIN PUBLIC KEY-----
MIGbMBAGByqGSM49AgEGBSuBBAAjA4GGAAQBCmmiMNZTXuR44GdzljZErCUcNgf5
jpCcPTL31HYx8EUdoFh4JC+4kUBFxTn7VzuHxFUBLYmNGO1Jow6QqpDfLb0B+2d4
vs4wjNqvFZ1ET79VDt1AcgySGWX8KlAgizrmIGwXJmp1UfewMhv2f5EDbu3vVU9m
f1WeP2aRaDHmG4ryVkg=
-----END PUBLIC KEY-----
这是 Base64 编码的 ASN.1 结构,包含了公钥的 x 和 y 坐标。
# 计算私钥字符串的 MD5 哈希
flag_md5 = md5(str(priv_int).encode()).hexdigest()
flag = f"flag{{{flag_md5}}}"
print(f"[*] Flag: {flag}")
注意事项:
题目要求flag{MD5(str(private_key))},因此必须:
将私钥整数转换为字符串:str(priv_int)
例如:"11786190273906782566706300546504742629011900435269701041731697414027484824601255112180676531145294320443777235338538357924760601782873554458995940394745073"
编码为字节:.encode()
使用默认的 UTF-8 编码
计算 MD5 哈希:md5(...).hexdigest()
hexdigest()返回十六进制字符串形式的哈希值
运行我们编写的脚本后,得到以下输出:
[*] 开始分析ECDSA私钥恢复漏洞...
[+] 步骤1: 复现私钥生成过程
- SHA-512哈希值: 11786190273906782566706300546504742629011900435269701041731697414027484824601255112180676531145294320443777235338538357924760601782873554458995940394745073
- 椭圆曲线阶: 6864797660130609714981900799081393217269435300143305409394463459185543183397655394245057746333217197532963996371363321113864768612440380340372808892707005449
- 私钥: 11786190273906782566706300546504742629011900435269701041731697414027484824601255112180676531145294320443777235338538357924760601782873554458995940394745073
[+] 步骤2: 生成签名密钥对象
- 已生成签名密钥和验证密钥
[+] 步骤3: 验证恢复的私钥是否正确
- [成功] 公钥匹配! 私钥恢复成功!
[+] 步骤4: 生成flag
- 计算 MD5(str(private_key))...
============================================================
[*] Flag: flag{581bdf717b780c3cd8282e5a4d50f3a0}
============================================================
私钥值:
11786190273906782566706300546504742629011900435269701041731697414027484824601255112180676531145294320443777235338538357924760601782873554458995940394745073
这是一个 521 位的整数。我们可以看到,SHA-512 哈希值小于曲线阶,所以私钥等于哈希值本身。
公钥匹配验证:
验证成功,说明我们恢复的私钥完全正确。这证实了我们的攻击思路是正确的。
Flag:
flag{581bdf717b780c3cd8282e5a4d50f3a0}
这就是最终答案。
将所有步骤整合在一起:
#!/usr/bin/env python3
from ecdsa import SigningKey, NIST521p, VerifyingKey
from hashlib import sha512, md5
from Crypto.Util.number import long_to_bytes
print("[*] 开始分析ECDSA私钥恢复漏洞...")
# 步骤1: 复现私钥生成过程
digest_int = int.from_bytes(sha512(b"Welcome to this challenge!").digest(), "big")
curve_order = NIST521p.order
priv_int = digest_int % curve_order
priv_bytes = long_to_bytes(priv_int, 66)
print(f"[+] 私钥: {priv_int}")
# 步骤2: 生成密钥对
sk = SigningKey.from_string(priv_bytes, curve=NIST521p)
vk_computed = sk.verifying_key
# 步骤3: 验证公钥
with open("public.pem", "rb") as f:
vk_file = VerifyingKey.from_pem(f.read())
if vk_computed.to_string() == vk_file.to_string():
print("[+] 公钥匹配! 私钥恢复成功!")
# 步骤4: 计算flag
flag_md5 = md5(str(priv_int).encode()).hexdigest()
print(f"[*] Flag: flag{{{flag_md5}}}")
else:
print("[-] 公钥不匹配,攻击失败")
在密码学中,私钥的安全性建立在以下假设上:
计算困难性假设: 给定公钥 Q = d × G,在不知道私钥 d 的情况下,反向计算 d 在计算上是不可行的。这就是椭圆曲线离散对数问题(ECDLP)。
对于 NIST P-521 曲线,解决 ECDLP 需要:
约 2^256 次运算(暴力破解)
即使使用最先进的计算机和算法,也需要数十亿年
然而,如果私钥生成是确定性的且生成参数已知,攻击者根本不需要解决 ECDLP,而是直接重现私钥生成过程。这完全绕过了椭圆曲线的数学安全性。
类比:
椭圆曲线的安全性 = 保险柜的复杂锁
确定性私钥生成 = 把钥匙放在保险柜上面
无论锁多么复杂,如果钥匙就在旁边,保险柜就毫无意义。
安全的私钥生成应该使用密码学安全的伪随机数生成器(CSPRNG):
# 方法1: 使用 secrets 模块(推荐)
import secrets
# 生成密码学安全的随机字节
random_bytes = secrets.token_bytes(66)
priv_int = int.from_bytes(random_bytes, "big") % NIST521p.order
# 确保私钥不为0
while priv_int == 0:
random_bytes = secrets.token_bytes(66)
priv_int = int.from_bytes(random_bytes, "big") % NIST521p.order
sk = SigningKey.from_string(long_to_bytes(priv_int, 66), curve=NIST521p)
# 方法2: 使用 ecdsa 库的内置方法(更推荐)
from ecdsa import SigningKey, NIST521p
# 库会自动使用安全的随机数生成器
sk = SigningKey.generate(curve=NIST521p)
为什么使用secrets模块:
Python 的random模块不适合密码学用途:
random.random()使用 Mersenne Twister 算法
这是一个伪随机数生成器(PRNG),不是 CSPRNG
如果知道部分输出,可以预测后续输出
secrets模块专门用于密码学:
使用操作系统提供的随机源(如 Linux 的/dev/urandom)
在 Windows 上使用CryptGenRandom
每次调用都会产生不可预测的结果
SHA-512 是一个优秀的密码学哈希函数,具有:
良好的性质:
抗碰撞性: 找到两个不同输入产生相同输出在计算上不可行
抗原像攻击: 给定输出,找到输入在计算上不可行
抗第二原像攻击: 给定一个输入,找到另一个产生相同输出的输入在计算上不可行
雪崩效应: 输入微小变化会导致输出剧烈变化
输出分布均匀: 输出在 0 到 2^512-1 之间均匀分布
但是,哈希函数是确定性的:
相同输入 → 相同输出(必然)
如果输入可预测 → 输出也可预测
在本题中:
输入字符串"Welcome to this challenge!"是公开的源代码的一部分
因此输出也是可预测的
任何人都可以计算出相同的哈希值
正确的使用场景:
哈希函数可以用于从随机输入派生密钥:
# 错误: 从固定字符串派生
priv = sha512(b"Welcome to this challenge!").digest()
# 正确: 从随机密钥派生多个子密钥
master_key = secrets.token_bytes(64) # 随机主密钥
key1 = sha512(master_key + b"key1").digest()
key2 = sha512(master_key + b"key2").digest()
题目提供了signatures.txt,包含 60 个消息及其签名:
6d6573736167652d00:01a76ff5e0a4490f314ab2a0650d4e9d6955fb154c39eeec2700fefac7b4aeef...
6d6573736167652d01:0048955974b1e4270bc53524e878c60e8664e2a71ae031deb7caba819024cf7f...
...
每行格式为消息(hex):签名(hex)。
虽然我们最终不需要使用这些签名,但它们可能有以下作用:
1. 混淆视听:
让参赛者误以为需要通过签名分析来恢复私钥,增加题目难度。
2. 备选攻击路径:
如果 nonce 生成存在某些特定漏洞,可以通过签名分析攻击。常见的签名分析攻击包括:
Nonce 重用攻击: 如果两个签名使用相同的 nonce,可以恢复私钥
Nonce 偏差攻击: 如果 nonce 的生成存在偏差,可以通过大量签名统计分析恢复私钥
侧信道攻击: 通过签名时间、功耗等侧信道信息攻击
在本题中,nonce 生成代码:
def nonce(i):
seed = sha512(b"bias" + bytes([i])).digest()
k = int.from_bytes(seed, "big")
return k
这里 nonce 也是确定性生成的,存在理论上的安全问题,但由于私钥生成漏洞过于明显,我们不需要进行复杂的签名分析。
对于想深入理解的读者,这里简单介绍椭圆曲线的数学基础:
椭圆曲线方程:
NIST P-521 使用 Weierstrass 形式:
y^2 = x^3 + ax + b (mod p)
点加法运算:
椭圆曲线上定义了点的加法运算,使得曲线上的点构成一个群。
点乘运算:
Q = d × G = G + G + ... + G (d 次)
为什么离散对数困难:
正向计算(点乘): 从 d 和 G 计算 Q,快速(使用快速幂算法)
反向计算(离散对数): 从 Q 和 G 计算 d,困难(没有已知的高效算法)
这种不对称性是椭圆曲线密码学的基础。
通过本题,我们可以总结出密码学实现中的常见错误:
1. 使用弱随机数生成器
# 错误
import random
key = random.randint(1, curve_order)
# 正确
import secrets
key = secrets.randbelow(curve_order)
2. 硬编码种子
# 错误
random.seed(12345)
key = random.randint(1, curve_order)
# 正确
# 不设置种子,使用系统随机源
3. 从可预测的值派生密钥
# 错误
key = hash(user_id)
# 正确
key = secrets.token_bytes(32)
4. 重用 Nonce
# 错误
k = 12345
sig1 = sign(msg1, k)
sig2 = sign(msg2, k) # 相同的 k!
# 正确
k1 = secrets.randbelow(curve_order)
k2 = secrets.randbelow(curve_order) # 不同的 k
类似的漏洞在历史上曾多次导致严重的安全事故:
案例 1: PlayStation 3 破解(2010)
Sony 在 PS3 的 ECDSA 签名实现中使用了固定的 nonce:
k = fixed_value # 每次签名都用相同的值!
后果:
安全研究人员通过两个签名恢复了 Sony 的私钥
整个 PS3 的签名系统崩溃
任何人都可以签名并运行自制软件
Sony 不得不发布系统更新并更换密钥
技术细节:
如果两个签名 (r1, s1) 和 (r2, s2) 使用相同的 k,则:
s1 = k^-1 * (H(m1) + r1 * d) mod n
s2 = k^-1 * (H(m2) + r2 * d) mod n
可以解出私钥 d:
d = (s1 * H(m2) - s2 * H(m1)) / (s1 * r2 - s2 * r1) mod n
案例 2: Android 比特币钱包漏洞(2013)
在某些 Android 版本上,Java 的SecureRandom在某些情况下未能正确初始化:
SecureRandom random = new SecureRandom();
// 在某些 Android 版本上,这可能返回可预测的值!
后果:
多个比特币钱包生成了可预测的私钥
攻击者通过猜测私钥盗取了用户的比特币
估计损失达数百万美元
案例 3: Debian OpenSSL 漏洞(2008)
Debian 的一个补丁错误地移除了 OpenSSL 随机数生成器的熵源:
// 原始代码
MD_Update(&m, buf, j); // buf 包含未初始化内存(熵源)
// Debian 补丁移除了这行(为了修复 Valgrind 警告)
// MD_Update(&m, buf, j); // 注释掉了!
后果:
SSH 密钥、SSL 证书的私钥变得可预测
攻击者可以暴力破解私钥(只有约 32,000 种可能)
影响了 2006-2008 年间在 Debian/Ubuntu 上生成的所有密钥
原则 1: 不要自己实现密码算法
# 错误: 自己实现 ECDSA
def my_ecdsa_sign(message, private_key):
# 自己实现的签名算法...
pass
# 正确: 使用标准库
from ecdsa import SigningKey
sk.sign(message)
原则 2: 使用正确的随机源
# 错误
import random
key = random.getrandbits(256)
# 正确
import secrets
key = secrets.token_bytes(32)
原则 3: 遵循标准和最佳实践
使用标准的椭圆曲线(NIST P-256, P-384, P-521, secp256k1)
遵循 RFC 文档的建议
使用经过审计的密码学库
原则 4: 代码审计
密钥生成代码应该接受专业的安全审计
进行代码复审(Code Review)
使用静态分析工具检测常见漏洞
原则 5: 测试与验证
# 测试随机性
keys = [generate_key() for _ in range(1000)]
assert len(set(keys)) == 1000 # 确保没有重复
# 测试正确性
sk = generate_key()
vk = sk.get_verifying_key()
sig = sk.sign(b"test")
assert vk.verify(sig, b"test") # 验证签名正确
当你看到一个密码学实现时,应该检查:
检查清单:
私钥是否使用密码学安全的随机数生成器?
是否使用了标准的、经过验证的密码学库?
是否有任何硬编码的密钥或种子?
Nonce/IV 是否每次都不同且不可预测?
是否遵循了相关的标准(如 FIPS, RFC)?
代码是否经过安全审计?
是否有适当的错误处理(但不泄露敏感信息)?
本题是一道经典的密码学安全漏洞利用题目,核心考点是:
技术考点:
代码审计能力: 能够通过阅读源代码识别出私钥生成中的安全漏洞
密码学理论: 理解 ECDSA 的密钥生成安全性要求和随机性的重要性
确定性攻击: 利用确定性过程重现私钥,绕过椭圆曲线的数学安全性
关键要点:
私钥必须是真正的随机数,不能通过任何确定性方法从已知值派生
哈希函数虽然安全,但不能替代随机数生成器
密码学实现中,任何偏离标准实践的行为都可能引入严重漏洞
密码学的安全性不仅依赖于数学难题,还依赖于正确的实现
安全教训:
永远使用标准的密码学库和方法
不要自己"创新"密钥生成方法
密钥生成是密码学系统的基石,必须格外小心
定期进行安全审计和代码复审
通过这道题,我们深刻理解了密码学实现中随机性的重要性,以及为什么必须严格遵循密码学最佳实践。在实际开发中,安全的密码学实现需要:
使用经过验证的标准库
遵循行业最佳实践
进行充分的测试和审计
保持对安全问题的警惕
最终 Flag:flag{581bdf717b780c3cd8282e5a4d50f3a0}