# Challenge 62: 对有偏差 nonce 的 ECDSA 密钥恢复攻击 回到第 6 组,我们看到 "nonce" 对于 DSA 中的 k 值来说有点名不副实。它更像是一个临时密钥。令人担忧的是,你的长期私钥的安全性依赖于它。 nonce 泄露?恭喜,你刚刚泄露了你的秘密密钥。 可预测的 nonce?同样如此。 甚至通过重复一个 nonce 你就失去了一切。 我们能把这个推进到多远?事实证明,相当远:即使 nonce 生成中的轻微偏差也足以让攻击者恢复你的私钥。让我们看看如何。 首先,让我们澄清我们所说的"有偏差" nonce 是什么意思。这种攻击真正需要的只是对 nonce 的几个位的了解。为了简单起见,假设每个 nonce 的低字节为零。所以拿你用于 nonce 生成的任何代码,只需屏蔽掉最低有效的八位。 这如何帮助我们?让我们回顾签名算法: ``` function sign(m, d): k := random_scalar(1, q) r := (k * G).x s := (H(m) + d*r) * k^-1 return (r, s) ``` (快速说明:之前我们使用 "n" 来表示基点的阶。在这个问题中,我将使用 "q" 以避免命名冲突。处理它。) 专注于 s 计算。观察到如果 k 的低 l 位偏向于某个常数 c,我们可以将 k 重写为 b*2^l + c。在我们的情况下,c = 0,所以我们将 k 重写为 b*2^l。这意味着我们可以这样关联公共的 r 和 s 值: ``` s = (H(m) + d*r) * (b*2^l)^-1 ``` 一些直接的代数让我们从这里到这里: ``` d*r / (s*2^l) = (H(m) / (-s*2^l)) + b ``` 记住这些计算都是模 q,基点的阶。现在,让我们定义一些替代品: ``` t = r / ( s*2^l) u = H(m) / (-s*2^l) ``` 现在我们上面的方程可以写成这样: ``` d*t = u + b ``` 记住 b 是小的。而 t、u 和秘密密钥 d 都大致是 q 的大小,b 大致是 q/2^l。这是一个舍入误差。由于 b 如此小,我们基本上可以忽略它并说: ``` d*t ~ u ``` 换句话说,u 是 d*t mod q 的近似。让我们进一步处理这些数字。由于这是模 q,我们可以说: ``` d*t ~ u + m*q 0 ~ u + m*q - d*t ``` 那个和不会真的是零 - 它只是一个近似。但它会小于某个界限,比如 q/2^l。关键是它相对于其他相关项会非常小。 如果我们有足够的 (u, t) 对,我们可以使用这个性质来恢复 d。但要做到这一点,我们需要了解一点线性代数。不会太多,我保证。 线性代数是关于向量的。向量可以是几乎任何东西,但为了简单起见,我们将说向量是一个固定长度的数字序列。我们可以对向量做两件主要的事情:我们可以相加它们,可以用标量乘以它们。要相加两个向量,只需将它们的对应分量相加。要用标量 k 乘以向量,只需将它加到自身 k 次。(等价地,将它的每个元素乘以标量。)这些运算一起被称为线性组合。 如果我们有一组向量,我们说它们张成一个向量空间。向量空间就是我们可以通过对集合中的向量进行加法和缩放生成的所有可能向量的完整范围。我们称最小张成集为向量空间的基。"最小"意味着从集合中删除我们的任何向量都会导致更小的向量空间。添加的向量要么是冗余的(即,它们可以定义为现有向量的和),要么它们会给我们一个更大的向量空间。所以你可以认为基对于它张成的向量空间来说是"恰到好处"的。 我们只使用整数作为我们的标量。仅使用整数标量生成的向量空间称为格。最好在二维平面上想象这个。假设我们的向量集是 {(3, 4), (2, 1)}。格包括这两对的所有整数组合。你可以在纸上画出来以了解想法;你应该得到某种波尔卡圆点图案。 我们说基对于它张成的向量空间来说是恰到好处的大小,但这不应该被理解为暗示唯一性。确实,我们将关心的任何格都有无限可能的基。唯一的要求是基张成空间,并且基的大小是最小的。在这个意义上,给定格的所有基都是相等的。 但有些基比其他基"更相等"。在实践中,人们喜欢使用由较短向量组成的基。这里"较短"大致意思是"平均包含较小的分量"。这里一个方便的测量棒是欧几里得范数:简单地取向量与自身的点积并取平方根。或者不取平方根,我不在乎。它不会影响排序。 为什么人们喜欢这些较小的基?主要是因为它们对计算更有效。老实说,为什么人们喜欢它们并不太重要。重要的是我们有相对有效的方法来"归约"基。给定一个输入基,我们可以产生一个等价但向量短得多的基。短多少?好吧,可能不是最短可能的,但相当短。 这暗示了一种非常巧妙的问题解决方法: 1. 将你的问题空间编码为一组向量,形成格的基。你选择的格应该包含你正在寻找的解作为短向量。你不需要知道这个向量(显然,因为你在寻找它),你只需要知道它作为你的基向量的某种整数组合存在。 2. 导出格的归约基。我们稍后再回到这个。 3. 从归约基中找出你的解向量。 4. 就这样。 等等,就这样?是的,你没听错 - 格基归约是一种极其强大的技术。它在 80 年代单枪匹马地粉碎了背包密码系统,从那时起就获得了大量奖杯。只要你能定义一个包含编码问题解的短向量的格,你就可以让它为你工作。 显然,定义格是棘手的部分。我们如何编码 ECDSA?好吧,当我们离开时,我们有以下近似: ``` 0 ~ u + m*q - d*t ``` 假设我们收集了一堆签名。然后那一个近似变成许多: ``` 0 ~ u1 + m1*q - d*t1 0 ~ u2 + m2*q - d*t2 0 ~ u3 + m3*q - d*t3 0 ~ u4 + m4*q - d*t4 0 ~ u5 + m5*q - d*t5 0 ~ u6 + m6*q - d*t6 ... 0 ~ un + mn*q - d*tn ``` 每个 u 的系数总是 1,t 的系数总是秘密密钥 d。所以很自然,我们应该将它们排列在两个向量中: ``` bt = [ t1 t2 t3 t4 t5 t6 ... tn ] bu = [ u1 u2 u3 u4 u5 u6 ... un ] ``` 每个近似还包含 q 的一些因子。但系数 m 每次都不同。这意味着我们需要每个都有单独的向量: ``` b1 = [ q 0 0 0 0 0 ... 0 ] b2 = [ 0 q 0 0 0 0 ... 0 ] b3 = [ 0 0 q 0 0 0 ... 0 ] b4 = [ 0 0 0 q 0 0 ... 0 ] b5 = [ 0 0 0 0 q 0 ... 0 ] b6 = [ 0 0 0 0 0 q ... 0 ] ... ... bn = [ 0 0 0 0 0 0 ... q ] bt = [ t0 t1 t2 t3 t4 t5 ... tn ] bu = [ u0 u1 u2 u3 u4 u5 ... un ] ``` 看到跨越我们的行向量的列如何与我们上面收集的近似匹配吗?还要注意,这个基定义的格包含至少一个我们感兴趣的相当短的向量: ``` bu - d*bt + m0*b1 + m1*b2 + m2*b3 ... + mn*bn ``` 但我们有一个问题:即使这个向量包含在我们的归约基中,我们也无法识别它。我们可以通过添加几个新列来解决这个问题。 ``` b1 = [ q 0 0 0 0 0 ... 0 0 0 ] b2 = [ 0 q 0 0 0 0 ... 0 0 0 ] b3 = [ 0 0 q 0 0 0 ... 0 0 0 ] b4 = [ 0 0 0 q 0 0 ... 0 0 0 ] b5 = [ 0 0 0 0 q 0 ... 0 0 0 ] b6 = [ 0 0 0 0 0 q ... 0 0 0 ] ... ... bn = [ 0 0 0 0 0 0 ... q 0 0 ] bt = [ t0 t1 t2 t3 t4 t5 ... tn ct 0 ] bu = [ u0 u1 u2 u3 u4 u5 ... un 0 cu ] ``` 我们在 bt 和 bu 中添加了两个带有哨兵值的新列。这将允许我们确定这两个向量是否包含在任何输出向量中以及以什么比例。(这不是这解决的唯一问题。我们的最后一组向量实际上不是一个基,因为我们有 n+2 个 n 度向量,所以其中显然有一些冗余。) 我们可以通过在我们的归约基的每个向量的最后一个槽中寻找 cu 来识别我们正在寻找的向量。我们的直觉是相邻的槽将包含 -d*ct,我们可以除以 -ct 来恢复 d。 好的。要进一步进行,我们需要深入了解基归约的具体细节。有不同的策略来为格找到归约基,但我们将专注于一个简单而有效的多项式时间算法:Lenstra-Lenstra-Lovasz (LLL)。 大多数人不实现 LLL。他们使用库,其中有几个优秀的库。NTL 是一个流行的选择。 仅出于教学目的,我们将编写自己的。 这里是一些伪代码: ``` function LLL(B, delta): B := copy(B) Q := gramschmidt(B) function mu(i, j): v := B[i] u := Q[j] return (v*u) / (u*u) n := len(B) k := 1 while k < n: for j in reverse(range(k)): if abs(mu(k, j)) > 1/2: B[k] := B[k] - round(mu(k, j))*B[j] Q := gramschmidt(B) if (Q[k]*Q[k]) >= (delta - mu(k, k-1)^2) * (Q[k-1]*Q[k-1]): k := k + 1 else: B[k], B[k-1] := B[k-1], B[k] Q := gramschmidt(B) k := max(k-1, 1) return B ``` B 是我们的输入基。Delta 是一个参数,使得 0.25 < delta <= 1。你可以将其设置为 0.99 并忘记它。 Gram-Schmidt 是一个算法,将基转换为相互正交("垂直"的花哨词)向量的等价基。它非常简单: ``` function proj(u, v): if u = 0: return 0 return ((v*u) / (u*u)) * u function gramschmidt(B): Q := [] for i, v in enumerate(B): Q[i] := v - sum(proj(u, v) for u in Q[:i]) return Q ``` Proj 找到 v 在 u 上的投影。这基本上是 v 在与 u 相同"方向"上的部分。如果 u 和 v 正交,这是零向量。Gram-Schmidt 通过遍历原始基并剔除这些投影来正交化基。 回到 LLL。了解它如何以及为什么工作的最佳方法是实现它并在一些小例子上测试它,输出大量调试信息。但基本上:我们在基 B 中上下走动,将每个向量 b 与正交化基 Q 进行比较。每当我们找到 Q 中与 b 大部分对齐的向量 q 时,我们就剔除 q 在 b 上的投影的整数近似。记住格处理整数系数,我们也必须如此。在每次迭代后,我们使用一些启发式来决定是否应该在 B 中前进或后退,是否应该交换一些行等等。 还有一件事:上面的 LLL 描述非常朴素且低效。它可能对我们的目的来说不够快,所以你可能需要稍微优化它。一个好的开始是不在每次更新时重新计算整个 Q 矩阵。 这里是一个测试基: ``` b1 = [ -2 0 2 0] b2 = [ 1/2 -1 0 0] b3 = [ -1 0 -2 1/2] b4 = [ -1 1 1 2] ``` 它归约为这个(delta = 0.99): ``` b1 = [ 1/2 -1 0 0] b2 = [ -1 0 -2 1/2] b3 = [-1/2 0 1 2] b4 = [-3/2 -1 2 0] ``` 我忘记提到:你会想编写你的实现以处理有理数向量。如果你有无限精度浮点数,那些也可以工作。 剩下的就是整理一些松散的结尾。首先,我们如何选择我们的哨兵值 ct 和 cu?这有点像实现细节,但我们想"平衡"我们目标向量中条目的大小。由于我们期望所有其他条目大约是 q/2^l 的大小: ``` ct = 1/2^l cu = q/2^l ``` 记住 ct 将被乘以 -d,而 d 大约是 q 的大小。 好的,你终于准备好运行攻击了: 1. 生成你的 ECDSA 秘密密钥 d。 2. 使用 d 和你的有偏差 nonce 生成器签名一堆消息。 3. 作为攻击者,收集你的 (u, t) 对。你可以尝试数量。使用八位 nonce 偏差,我在仅 20 个签名的情况下就得到了良好的结果。你的里程可能会有所不同。 4. 将你的值填入矩阵并用 LLL 归约它。考虑用一些较小的矩阵来了解这将需要运行多长时间。 5. 在归约基中,找到最后一项为 q/2^l 的向量。很有可能它的倒数第二项为 -d/2^l。提取 d。