从ECDSA随机数重用漏洞到私钥破解:CTF实战与数学推导

📅 2026/7/29 4:09:09 👁️ 阅读次数 📝 编程学习
从ECDSA随机数重用漏洞到私钥破解:CTF实战与数学推导

1. 项目概述:当ECDSA签名不再“安全”

在CTF的密码学赛道上,ECDSA(椭圆曲线数字签名算法)相关的题目一直是区分选手水平的一道分水岭。它不像基础的RSA那样有大量现成的攻击脚本,也不像AES对称加密那样直观。ECDSA以其数学上的优雅和公认的安全性著称,广泛应用于比特币、TLS等关键领域。然而,正是这种“公认的安全”,让许多CTFer在遇到相关题目时感到无从下手,觉得它是个黑盒。实际上,ECDSA的安全性严重依赖于其实现过程中的每一个细节,一旦这些细节出现纰漏——比如随机数k被重复使用、泄露,或者签名过程中存在侧信道泄露——整个签名体系就会土崩瓦解。这个项目,就是带你亲手拆解这个“黑盒”,从零开始,理解ECDSA的工作原理,并实战演练如何利用其常见的实现漏洞来破解签名,最终拿到Flag。我会用最直白的Python代码,一步步还原攻击过程,让你不仅会“用”脚本,更明白脚本每一行背后的数学逻辑和攻击原理。

2. ECDSA核心原理与安全基石拆解

在动手破解之前,我们必须先搞清楚我们要攻击的对象到底是什么,它的弱点可能藏在哪里。ECDSA可以看作是在椭圆曲线这个数学结构上实现的“数字签名版DSA”。它的安全性根基在于椭圆曲线离散对数问题(ECDLP)的困难性,简单说,就是从公钥Q反推出私钥d是计算上不可行的。但算法是完美的,实现是人写的,人就会犯错。

2.1 签名与验证的数学流程

假设我们有一条选定的椭圆曲线(比如经典的secp256k1),一个基点G,其阶为n(一个非常大的素数)。用户持有一个私钥d(一个在[1, n-1]区间内的随机整数),公钥Q = d * G(椭圆曲线上的点乘运算)。

签名过程(Sign)

  1. 对消息m计算哈希值e = Hash(m)。(例如使用SHA-256)
  2. 生成一个临时随机数k,同样在[1, n-1]区间内。这个k至关重要,也是绝大多数漏洞的源头。
  3. 计算椭圆曲线点 (x1, y1) = k * G。
  4. 令 r = x1 mod n。如果r=0,则返回第2步重选k。
  5. 计算 s = k^{-1} * (e + d * r) mod n。如果s=0,也返回第2步。
  6. 得到的(r, s)就是消息m的数字签名。

验证过程(Verify)

  1. 检查r和s是否都在[1, n-1]区间内。
  2. 计算 e = Hash(m)。
  3. 计算 w = s^{-1} mod n。
  4. 计算 u1 = e * w mod n, u2 = r * w mod n。
  5. 计算椭圆曲线点 (x1, y1) = u1 * G + u2 * Q。
  6. 验证 r == x1 mod n。若相等,则签名有效。

从流程看,验证方只需要公钥Q、消息m和签名(r, s),完全不需要私钥d或随机数k。整个系统的安全假设是:k必须是一次一密,且绝对保密。

2.2 常见漏洞模式分析

CTF中ECDSA的题目,几乎都是围绕破坏这个安全假设展开的:

  1. 随机数k重复使用:这是最经典、最著名的漏洞。如果对两个不同的消息m1和m2,签名时使用了同一个k,那么攻击者可以直接解出私钥d。
  2. 随机数k部分泄露或可预测:如果k的某些比特位泄露(例如通过侧信道攻击),或者k是由一个脆弱的伪随机数生成器(PRNG)产生的,攻击者可能利用格基规约(LLL算法)等数学工具恢复出私钥。
  3. 签名过程中存在故障注入:在计算s = k^{-1} * (e + d * r) mod n时,如果通过某种物理手段(如电压毛刺)导致计算错误,可能会产生无效签名,分析这些错误签名有时也能泄露信息。
  4. 签名参数(如r, s)的某些性质被利用:例如,s值过小、存在某种数学关系等,在特定场景下可能被攻击。

我们本次实战将聚焦于第一种情况——随机数k重复使用。因为它的原理最直观,攻击代码最简洁,非常适合作为入门ECDSA攻击的第一课。理解了它,你就掌握了破解一半以上相关CTF题目的钥匙。

3. 攻击场景构建与Python环境准备

为了模拟一个真实的CTF漏洞场景,我们假设遇到这样一个题目:服务器使用了一个有缺陷的ECDSA签名库,在对多条不同的消息进行签名时,意外地重复使用了同一个随机数k。我们的任务是,通过收集到足够多的(消息,签名)对,推导出私钥d,然后伪造签名通过验证,从而获取Flag。

3.1 核心工具与库选择

我们将使用Python进行攻击演示,主要依赖ecdsahashlib库。ecdsa库本身是安全的,我们将用它来“正确”地生成密钥、签名和验证,以模拟目标系统。而我们的攻击代码,则会基于数学原理从头编写,不依赖任何现成的攻击函数,以此加深理解。

# 安装必要的库 pip install ecdsa

hashlib是Python标准库,无需安装。我们选择secp256k1曲线进行演示,因为它应用广泛(比特币就用它),且原理通用。

3.2 模拟漏洞签名生成

首先,我们写一段模拟有漏洞的签名服务器代码。关键点在于,我们固定一个k值,并用它对多条不同的消息进行签名。

import ecdsa import hashlib import random # 选择曲线 curve = ecdsa.SECP256k1 n = curve.order # 曲线的阶,一个非常大的素数 # 生成一对正常的密钥 private_key = ecdsa.SigningKey.generate(curve=curve) public_key = private_key.get_verifying_key() print(f"[*] 生成的公钥坐标: ({public_key.pubkey.point.x()}, {public_key.pubkey.point.y()})") # 模拟漏洞:固定一个随机数k(在实际漏洞中,这是无意发生的) k_fixed = random.randrange(1, n) # 随机选一个k,但之后固定不变 print(f"[*] 被重复使用的致命随机数 k = {k_fixed}") # 准备两条不同的消息 messages = [b"Hello, CTF!", b"ECDSA is broken if k is reused."] signatures = [] for msg in messages: # 计算消息哈希 e = int(hashlib.sha256(msg).hexdigest(), 16) % n # 使用固定的k进行签名(模拟漏洞) # 计算 r = (k * G).x mod n kG = k_fixed * curve.generator r = kG.x() % n # 计算 s = k^{-1} * (e + d * r) mod n d = private_key.privkey.secret_multiplier k_inv = pow(k_fixed, -1, n) # Python 3.8+ 支持模逆计算 s = (k_inv * (e + d * r)) % n signatures.append((r, s)) print(f"[*] 消息: {msg.decode()}") print(f" 签名 (r, s): ({r}, {s})") # 用标准库验证签名是否正确(确认我们的模拟是有效的) for msg, (r, s) in zip(messages, signatures): sig = ecdsa.ecdsa.Signature(r, s) if public_key.pubkey.verifies(e, sig): print(f"[+] 签名验证通过: {msg.decode()}") else: print(f"[-] 签名验证失败!")

运行这段代码,我们就得到了一个关键的“战场环境”:两条不同消息m1,m2,它们对应的哈希e1,e2,以及使用同一个k生成的两组签名(r1, s1)(r2, s2)。注意,因为k相同,所以第一步计算的椭圆曲线点k*G相同,因此r1 = r2 = r。这是我们攻击的起点。

注意:在实际CTF题目中,你通常拿不到k的值,也拿不到私钥d。你拿到的是公开的公钥Q、若干条消息及其签名(r, s)。我们的目标是从这些公开信息中推出d

4. 破解实战:从重复的k到私钥d

现在,我们进入最核心的环节:如何利用k重复使用这一漏洞,从公开信息中解出私钥d。这个过程是一道漂亮的数学推导。

4.1 数学推导过程

我们有两条签名方程,因为k相同,所以r也相同:

  1. s1 = k^{-1} * (e1 + d * r) mod n
  2. s2 = k^{-1} * (e2 + d * r) mod n

注意,这里的k^{-1}是k在模n下的乘法逆元。我们将两个方程相减(在模n运算下):

s1 - s2 = k^{-1} * (e1 + d*r) - k^{-1} * (e2 + d*r) mod ns1 - s2 = k^{-1} * (e1 - e2) mod n

看,方程中的d被消掉了!现在我们得到了一个只包含s1, s2, e1, e2和未知数k^{-1}的方程。我们可以解出k^{-1},进而解出k

k^{-1} = (s1 - s2) * (e1 - e2)^{-1} mod n因此,k = (e1 - e2) * (s1 - s2)^{-1} mod n

一旦我们知道了k,就可以将它代入任何一个原始的签名方程来解出私钥d。例如,从第一个方程:s1 = k^{-1} * (e1 + d * r) mod n两边乘以kk * s1 = e1 + d * r mod n所以,d * r = (k * s1 - e1) mod n最终,d = (k * s1 - e1) * r^{-1} mod n

大功告成!私钥d被我们推导出来了。整个攻击过程,我们只需要两条使用相同k签名的消息及其哈希值。

4.2 Python攻击代码实现

现在,我们把上面的数学公式翻译成Python代码。假设我们处于攻击者视角,我们只知道:公钥Q、两条消息m1, m2、以及它们的签名(r, s1)(r, s2)(注意r相同)。

import hashlib # 攻击者已知的信息(从题目或网络流量中获取) # 公钥 Q (这里我们从模拟代码中获取公钥点,实际题目可能以字节或坐标形式给出) Q = public_key.pubkey.point # 两条消息 m1, m2 = messages # 两个签名 (r, s1), (r, s2) (r1, s1), (r2, s2) = signatures # 由于k重复使用,r1 等于 r2 r = r1 assert r1 == r2, "k未重复使用,无法进行此攻击!" # 1. 计算消息哈希 e1, e2 def hash_message(msg): return int(hashlib.sha256(msg).hexdigest(), 16) % n e1 = hash_message(m1) e2 = hash_message(m2) # 2. 计算 k = (e1 - e2) / (s1 - s2) mod n # 注意模运算下的除法是乘以模逆元 s_diff_inv = pow((s1 - s2) % n, -1, n) k_recovered = ((e1 - e2) * s_diff_inv) % n print(f"[+] 恢复出的随机数 k: {k_recovered}") print(f" 与真实的k是否一致? {k_recovered == k_fixed}") # 3. 计算私钥 d = (k * s1 - e1) / r mod n r_inv = pow(r, -1, n) d_recovered = ((k_recovered * s1 - e1) * r_inv) % n print(f"[+] 恢复出的私钥 d: {d_recovered}") print(f" 与真实的私钥是否一致? {d_recovered == private_key.privkey.secret_multiplier}") # 4. 验证:使用恢复的私钥对一条新消息签名,并用公钥验证 print(f"\n[*] 攻击验证阶段:使用恢复的私钥进行签名") recovered_priv_key = ecdsa.SigningKey.from_secret_exponent(d_recovered, curve=curve) test_msg = b"Flag: I_Stole_Your_Private_Key!" sig = recovered_priv_key.sign(test_msg, k=k_recovered) # 注意,这里我们“知道”了k,实际攻击中无法指定 if public_key.verify(sig, test_msg): print(f"[+] 攻击成功!恢复的私钥有效,可以伪造签名。") else: print(f"[-] 攻击失败。")

运行这段攻击代码,你会看到控制台输出成功恢复了k和私钥d。这完美演示了“随机数k重复使用”漏洞的致命性。

4.3 关键细节与边界处理

在编写攻击脚本时,有几个细节必须注意,否则很容易在CTF比赛中卡住:

  1. 模运算处理:Python的%运算符对于负数取模的结果可能不是我们想要的(数学上同余的正数)。例如,(s1 - s2) % n确保了结果在[0, n-1]之间。在计算模逆pow(a, -1, n)时,必须保证an互质(在ECDSA中,由于n是素数,只要a不是n的倍数就成立)。
  2. 哈希与截断:ECDSA签名时,对消息哈希值e的处理是e = Hash(m) mod n。如果哈希输出长度(如SHA-256是256位)大于n的位长度,需要取模。我们的hash_message函数已经做了这个处理。
  3. r=0或s=0的检查:在真正的ECDSA签名规范中,如果计算出的r或s为0,必须重新选择k。我们的模拟代码省略了这一步以简化流程,但攻击代码需要能处理题目给出的任何有效签名。
  4. 公钥格式转换:实际CTF题目中,公钥可能以PEM格式、十六进制字符串或坐标对(x, y)给出。你需要根据题目提示,将其正确加载为椭圆曲线点对象。ecdsa库提供了VerifyingKey.from_pem(),from_string()等方法。

实操心得:在真实解题时,拿到题目第一步不是急着写代码,而是先人工推导。拿出纸笔,根据题目描述写出签名方程。确认是否存在k重用(看r值是否相同),或者是否存在其他关系(比如多个签名共享了k的某些比特)。把数学模型理清,代码只是翻译工具。

5. 漏洞拓展与高级攻击场景

掌握了基础攻击后,我们来看看CTF中可能出现的其他变种和更复杂的情况。这能帮助你在赛场上快速识别题目类型。

5.1 随机数k部分泄露(LSB泄露)

这是比完全重用更隐蔽、也更常见于现实世界和CTF赛题的漏洞。假设由于侧信道攻击,我们知道了随机数k的最低有效位(LSB),或者知道了k满足某个线性关系,例如k = a * k' + b,其中k'很小。

攻击通常使用格基规约(LLL算法)。其核心思想是将签名方程转化为一个格上的最近向量问题。对于k的部分泄露,我们可以构造一个格,使得包含私钥d的短向量就在这个格中。使用SageMath(内置LLL)可以很方便地求解。

# 以下是一个概念性示例,实际需要SageMath环境 # 假设已知:多个签名 (r_i, s_i),对应消息哈希 e_i,且已知每个 k_i 的低位 bits_leaked # 我们可以写出:k_i = bits_leaked_i + 2^l * x_i,其中 x_i 是未知的高位。 # 代入签名方程:s_i = k_i^{-1}(e_i + d * r_i) mod n # 可以转化为关于 d 和 x_i 的线性方程,并构建格。 # 具体构造较为复杂,此处不展开代码,但思路是:将问题转化为寻找格中的短向量。

遇到这类题目,通常的线索是题目描述中提到了“侧信道”、“故障注入”、“随机数生成器有缺陷”或直接给出了k的部分信息。工具上优先考虑使用SageMath。

5.2 签名值s过小或存在线性关系

有时,题目并非直接攻击k,而是利用签名结果(r, s)本身。例如,如果要求签名中的s值非常小(比如小于某个阈值),或者多个签名之间存在s_i = a * s_j + b mod n这样的关系,也可能结合其他条件构造出攻击。

这类题目更偏向于数学技巧和观察。解题时,需要将收集到的所有签名方程并列出来,尝试通过线性组合消去未知数,或者利用中国剩余定理(CRT)等工具。

5.3 实战CTF题目模式解析

根据经验,CTF中的ECDSA题目通常呈现以下模式:

  1. “经典重现”型:直接给出多组消息和签名,其中r值相同。这就是我们刚才练习的,直接套用公式即可。
  2. “网络流量”型:提供一个pcap文件,你需要从中提取出多次签名通信的记录。使用Wireshark过滤TLS握手或特定应用层协议,找到证书、签名等字段,解析出r,s,e。挑战在于数据提取和格式解析。
  3. “服务器交互”型:给你一个网络地址和端口,你可以提交消息让服务器签名(但无法获取私钥),或者服务器会用自己的私钥签名某些信息给你。你需要设计交互,获取到足够多利用漏洞的签名对。这可能涉及到构造特定消息、触发错误状态等。
  4. “混合密码”型:ECDSA与其他密码算法结合。比如,用ECDSA签名一个AES密钥,或者签名一个RSA参数。你需要先破解ECDSA部分拿到关键参数,再继续下一步。

6. 防御措施与安全编程启示

作为攻击者,我们乐见漏洞;但作为开发者,我们必须避免它们。通过这次破解实战,我们应该深刻理解到:

  1. 绝对不可重复使用随机数k:这是铁律。每次签名都必须生成密码学安全的、不可预测的新随机数。
  2. 使用安全的随机数源:在生成k时,必须使用操作系统提供的密码学安全随机数生成器(CSPRNG),如/dev/urandom(Linux)、CryptGenRandom(Windows)或secrets.randbits()(Python 3.6+)。绝对禁止使用random.randint()或基于时间的种子。
  3. 考虑确定性ECDSA(RFC 6979):为了解决随机数生成的问题,RFC 6979定义了一种确定性ECDSA。它通过私钥d和消息m的哈希值,使用HMAC-DRBG算法确定性地生成k。这样,对于相同的消息和私钥,总会生成相同的签名,完全消除了随机数风险。许多现代库(如ecdsa库)默认或提供选项使用RFC 6979。
  4. 代码审计与测试:在安全关键代码中,对签名函数进行模糊测试和静态分析,检查是否存在随机数状态重置或共享的情况。
# 安全签名示例(使用RFC 6979) from ecdsa import SigningKey, SECP256k1 import hashlib sk = SigningKey.generate(curve=SECP256k1) message = b"critical transaction" # 默认情况下,`sign`方法可能已采用RFC 6979,但最好显式确认或使用支持它的库。 # 使用`ecdsa`库并确保使用`deterministic=True`参数(如果支持)。 sig = sk.sign(message, hashfunc=hashlib.sha256) # 检查库文档以确认其随机数生成方式

7. 常见问题与调试技巧实录

在真正解题或复现攻击时,你肯定会遇到各种报错和意外。这里记录几个我踩过的坑和解决方法:

  1. “Invalid signature” 验证失败

    • 检查哈希算法:确保你计算消息哈希时使用的算法与签名方一致(SHA-1? SHA-256?)。有时题目会使用非标准哈希。
    • 检查数据格式rs是大整数,但题目可能以十六进制字符串、Base64或字节形式给出。公钥也可能有多种编码格式(压缩、未压缩)。仔细阅读题目说明,进行正确的解码和类型转换。
    • 检查模数n:确认你使用的曲线和阶n是否正确。不同曲线的n不同。
  2. 恢复出的私钥d验证不通过

    • 检查符号:在计算s1 - s2e1 - e2时,确保模运算处理了负数。使用(a - b) % n来保证结果为正。
    • 检查方程代入:最稳妥的方法是,用恢复的dk,重新按照签名方程计算一遍s‘,看是否等于题目给出的s。如果不等于,逐步回溯计算每一步的中间值,与你的攻击代码输出对比。
    • 消息编码:对同一条消息,不同的编码(如是否包含换行符、是否进行URL编码)会产生不同的哈希值。确保你签名的消息字节与验证方完全一致。
  3. 使用SageMath进行格攻击时无解

    • 检查格构造是否正确:这是最复杂的一步。仔细阅读相关论文(如HNP: Hidden Number Problem)或成熟的CTF题解,对照检查你的格矩阵构造是否一致。一个系数的符号错误都可能导致失败。
    • 调整格维度与界限:LLL算法找到的向量不一定就是目标向量。可能需要尝试调整格的维度(使用的签名数量)和权重参数。
  4. 题目看似是ECDSA但无从下手

    • 寻找非标准参数:检查题目是否使用了自定义的椭圆曲线(弱曲线)、特殊的基点G、或者修改了签名验证公式。有时漏洞就藏在非标准实现里。
    • 寻找旁路信息:题目描述、注释、甚至变量名有时会给出提示,如leakhintfault等。

最后,分享一个我最常用的调试技巧:单元测试式攻击。在写出完整的攻击脚本前,先用模拟代码生成一个带有已知漏洞(如固定k)的密钥和签名对。然后,用你正在编写的攻击脚本去攻击这个你自己生成的、结果已知的“靶子”。这样能快速定位是数学公式错了,还是代码实现错了。当你的脚本能稳定攻破自己的模拟靶场后,再去挑战真正的题目,成功率会高很多。

密码学攻击就像解谜,每一步都需要严密的逻辑。从理解原理,到推导公式,再到代码实现,最后调试成功,这个过程带来的成就感,正是CTF竞赛和密码学研究的魅力所在。希望这篇从零开始的实战指南,能成为你解开下一个ECDSA签名漏洞题目的钥匙。