LLL アルゴリズムの Crypto 問への適用

Sat Jan 02 2021

以前参加した Harekaze mini CTF 2020 の Crypto 問 "Wilhelmina says" の復習です。 LLL (Lenstra-Lenstra-Lovász) を 1 ミリも知らなかったため 1 から勉強しました。そのメモです。

書籍としては 格子暗号解読のための数学的基礎 を読みました。数学的な話を丁寧にやってくれていて読みやすかったです。 この記事では上記問題を解くために必要な背景知識等をまとめるのにとどめ、数学的な話は一切排除します。また具体的な実装にも踏み込みません。あくまで Crypto 問に適用するときにどういったことに注意すればいいかについて主眼を当てます。

LLL アルゴリズム関連の用語

格子

格子とは、ある一次独立なベクトル biRm(1in)\bm{b}_i \in \R^m (1 \le i \le n) の整数係数の線形結合全体の集合です。式で書くと、

L(b1,,bn)={i=1naibiRmaiZ}\mathcal{L}(\bm{b}_1, \cdots, \bm{b}_n) = \left\{\sum_{i=1}^n a_i \bm{b}_i \in \R^m | a_i \in \Z\right\}

となります。格子の元を格子ベクトル、格子を成す基底を格子基底と呼びます。また

B=(b1bn)B = \left( \begin{array}{c} \bm{b}_1\\ \vdots\\ \bm{b}_n \end{array} \right)

を格子基底行列と呼びます。

ある格子を成す基底は2次元以上で無数に存在します。これはあるユニモジュラ行列 TT を考えた時、

(c1cn)=T(b1bn)\left( \begin{array}{c} \bm{c}_1\\ \vdots\\ \bm{c}_n \end{array} \right) = T \left( \begin{array}{c} \bm{b}_1\\ \vdots\\ \bm{b}_n \end{array} \right)

となる cc が格子基底となることから示せます (2次元以上でユニモジュラ行列は無限に存在する)。

LLL アルゴリズムは格子とそれを成すある格子基底行列が与えられたときに、その格子を成す格子基底のうちノルムが最小な基底を近似的に計算するアルゴリズムとなります。

逐次最小

格子 LL の中で原点を除いて原点から最も近い格子ベクトル TOP ii のノルム距離を考えます。式で書くと

λi(L)=minb1biLmax{b1,,bi}\lambda_i(L) = \min_{\bm{b}_1\cdots\bm{b}_i \in L} \max \{ \| \bm{b}_1 \|, \cdots, \| \bm{b}_i \| \}

これを逐次最小 λi\lambda_i と呼びます。

最短ベクトル問題 (Shortest Vector Problem, SVP)

最短ベクトル問題 (Shortest Vector Problem, SVP) は v=λ1(L)\| \bm{v} \| = \lambda_1(L) を満たす格子 LL 上の非零格子ベクトル v\bm{v} を求める問題です。 この SVP が暗号理論でよく用いられるのですが、SVP は NP-hard であることが知られています。 LLL アルゴリズムはこの逐次最小な格子基底を近似的に求める方法と言い換えられ、 v=γ(n)λ1(L)(γ1)\| \bm{v} \| = \gamma(n) \lambda_1(L) (\gamma \ge 1) となる v\bm{v} を効率的に探すことのできるアルゴリズムです。

基底簡約

例えば2次元系で格子基底が b1=(1,0),b2=(0,1)\bm{b}_1 = (1, 0), \bm{b}_2 = (0, 1) となる格子を考えてみます。この格子を LL とします。 前述通り LL を成す格子基底は無数に存在し、例えば c1=(1,2),c2=(1,3)\bm{c}_1 = (1, 2), \bm{c}_2 = (1, 3) も格子 LL を成します。 実はこのような格子を成す格子基底のうち、この b1,b2\bm{b}_1, \bm{b}_2 はノルムが最小なベクトルとなっています。つまり逐次最小基底です。 c1,c2\bm{c}_1, \bm{c}_2 のような格子基底を与えられたときに逐次最小な基底を求めることを基底簡約と呼びます。

LLL アルゴリズムは本来基底簡約のアルゴリズムなのですが、その過程で SVP を解くことにつながります。

LLL アルゴリズムの概要

LLL アルゴリズムは、雑に説明すると Gram-Schmidt の直交化で計算された基底に対し、ベクトルのノルムが小さくなるように基底を更新していくことで基底簡約を行うアルゴリズムです。 LLL アルゴリズムで簡約された格子基底を bi\bm{b}_i とすると、

Πi=1nbiαn(n1)4detL\Pi_{i=1}^n \| \bm{b}_i \| \le \alpha^{\frac{n(n-1)}{4}} \left| \det L \right|

となることが数学的に保証されています。ここで α\alpha14<δ<1\frac{1}{4} < \delta < 1 を用いて α=44δ1>43\alpha = \frac{4}{4\delta - 1} > \frac{4}{3} と表される定数です。 特に、

b1αn14detL1n\| \bm{b}_1 \| \le \alpha^{\frac{n-1}{4}} \left| \det L \right|^{\frac{1}{n}}

が成立します。nn が大きくなると LLL アルゴリズムで求まる格子基底と最短ベクトルの乖離も大きくなることに注意が必要です。

Crypto 問題への適用

それではこの LLL アルゴリズムが Crypto 問題でどのように使われるかを見ていきましょう。

問題

問題の repository は https://github.com/TeamHarekaze/harekaze-mini-ctf-2020-challenges-public/tree/main/crypto/wilhelmina-says になります。

task.py
from Crypto.Util.number import getStrongPrime
import random

p = getStrongPrime(512)

with open("flag", "rb") as f:
  flag = int.from_bytes(f.read().strip(), "big")
  assert flag < p

t = flag.bit_length()
n = 5
k = 350

xs = [random.randint(2, p-1) for _ in range(n)]
ys = [x * flag % p for x in xs]
zs = [(y >> k) << k for y in ys]

print(f"{t=}")
print(f"{p=}")
print(f"{xs=}")
print(f"{zs=}")

ある 512 bit の素数 pp を用意し、 xix_irandint(2, p-1) で生成され、yixi×flagmodpy_i \equiv x_i \times flag \mod p を計算した後、 yiy_i の下 k=350k=350 bit を落としたものを ziz_i としています。 tt (フラグの bit 数), pp, xi(0i<5)x_i (0\le i < 5), zi(0i<5)z_i (0\le i < 5) が与えられ、フラグとなる数値を求める問題です。

これが先述した LLL アルゴリズムとどう関係してくるのでしょうか。

解法

まず問題の式を整理していきます。フラグの数値を ff とします。 350 bit 以下の数 ti(zi)t_i (\ll z_i) を使って、 yi=zi+tiy_i = z_i + t_i とかけます。 また yiy_ixifx_ifpp で割った余りなので、 yi=xifpqiy_i = x_i f - pq_i とも表せます。2式組み合わせると、

ti=xifpqizit_i = x_i f - p q_i - z_i

となります。

この式を線形計算っぽく書いてみることにします。

(t0,t1,t2,t3,t4,fc0,c1)=(q0,q1,q2,q3,q4,f,1)(p0000000p0000000p0000000p0000000p00x0x1x2x3x4c00z0z1z2z3z40c1)(t_0, t_1, t_2, t_3, t_4, fc_0, -c_1) = (-q_0, -q_1, -q_2, -q_3, -q_4, f, -1) \left( \begin{array}{ccccccc} p & 0 & 0 & 0 & 0 & 0 & 0 \\ 0 & p & 0 & 0 & 0 & 0 & 0 \\ 0 & 0 & p & 0 & 0 & 0 & 0 \\ 0 & 0 & 0 & p & 0 & 0 & 0 \\ 0 & 0 & 0 & 0 & p & 0 & 0 \\ x_0 & x_1 & x_2 & x_3 & x_4 & c_0 & 0 \\ z_0 & z_1 & z_2 & z_3 & z_4 & 0 & c_1 \\ \end{array} \right)

ここで変数 c0,c1c_0, c_1 を追加して、右辺の行列を正方行列にしています。c0,c1c_0, c_1 を除けば右辺の行列はすべて given な値となっています (そうなるようにうまく書いてます)。 右辺の行列を格子基底行列とみなすと、この式は格子基底を (q0,q1,q2,q3,q4,f,1)(-q_0, -q_1, -q_2, -q_3, -q_4, f, -1) の重みで足し合わせると左辺の格子ベクトルとなる、といった見方ができます。 今 tit_i は右辺の各種値と比較すると十分小さい値となっているため、 c0,c1c_0, c_1 を適切な大きさの整数とすると、この左辺を求めることは SVP を解くことに対応します。つまりここで LLL アルゴリズムを使うことができるわけです。

c0,c1c_0, c_1 はどのような値にすればいいのかを考えます。最短ベクトルの不等式を再掲すると、α=2\alpha=2 として

b12n14detL1n\| \bm{b}_1 \| \le 2^{\frac{n-1}{4}} \left| \det L \right|^{\frac{1}{n}}

が成立する必要があるため、 c0,c1c_0, c_1 は最低でもこれを満たす必要があります。 今 detL=c0c1p5\left| \det L \right| = c_0 c_1 p^5 なので、右辺は

2714detL1/72logc0+logc17+1.5+512×572^{\frac{7-1}{4}}\left| \det L \right|^{1/7} \approx 2^{\frac{\log c_0+\log c_1}{7} + 1.5 + 512\times\frac{5}{7}}

と計算されます。 左辺のノルムは、(t0,,t4,fc0c1)(t_0, \cdots, t_4, fc_0 -c_1) で最も大きい値で近似できます。そのため、 c0=1,c1=2kc_0 = 1, c_1 = 2^k としてあげれば左辺は 2k2^k 程度になります。 指数部を比較すると、 k=350417.2k = 350 \le 417.2 となり不等式が成立していることが計算できます。

このような c0,c1c_0, c_1 を使って実際に LLL アルゴリズムを適用してみます。 SageMath を使用します。

t = 311
p = 10701453001723144480344017475825280248565900288828152690457881444597242894870175164568287850873496224666625464545640813032441546675898034617104256657175267
xs = [
    7891715755203660117196369138472423229419020799191062958462005957463124286065649164907374481781616021913252775381280072995656653443562728864428126093569737,
    9961822260223825094912294780924343607768701240693646876708240325173173602886703232031542013590849453155723572635788526544113459131922826531325041302264965,
    7554718666604482801859172289797064180343475598227680083039693492470379257725537783866346225587960481867556270277348918476304196755680361942599070096169454,
    5460028735981422173260270143720425600672799255277275131842637821512408249661961734712595647644410959201308881934659222154413079105304697473190038457627041,
    8621985577188280037674685081403657940857632446535799029971852830016634247561494048833624108644207879293891655636627384416153576622892618587617669199231771,
]
zs = [
    2445678981428533875266395719064486897322607935804981139297064047499983860197487043744531294747013763946234499465983314356438694756078915278742591478169600,
    6687262023290381303903301700938596216218657180198116459210103464914665663217490218525920847803014050091904359944827298080739698457116239163607201903280128,
    9144515139738671257281335253441395780954695458291758900110092599410878434836587336752247733779617485272269820837813132894795262162555273673307500761317376,
    7005359236736263649027110410188576532095684249796929034336135801107965605961711614006159825405033239188458945408990893269975105260656611838449490684018688,
    4638291797440604671051855904609667375394026160401800326727058461541969151082727684535417653507524951373254537356784859777006179731400955193335900924805120,
]

k = 350
n = 5

# 格子基底行列 L の生成
tmp_matrix = [[0 for _ in range(n+2)] for _ in range(n+2)]
for i in range(n):
    tmp_matrix[i][i] = p
for i in range(n):
    tmp_matrix[n][i] = xs[i]
for i in range(n):
    tmp_matrix[n+1][i] = zs[i]
tmp_matrix[n][n] = 1
tmp_matrix[n+1][n+1] = 2**k
L = Matrix(ZZ, tmp_matrix)

B = L.LLL()  # LLL アルゴリズムによる基底簡約
f = B[0][n]  # fc_0 = f
tmp = hex(abs(f))[2:]
if len(tmp) % 2 == 1:
    tmp = tmp.zfill(len(tmp)+1)
flag = bytes.fromhex(tmp)
print(flag)

HarekazeCTF{H0chmu7_k0mm7_v0r_d3m_F411}

このようにしてフラグを求めることができました。

おまけ

この問題において、本当に n=5n=5 つも xi,zix_i, z_i の組は必要だったのでしょうか。 nn を一般化して先程の不等式を考えます。このとき左辺は変わらず 2k2^k 程度となります。 右辺の指数部を計算すると

kn+2+n+14+512×nn+2\frac{k}{n+2} + \frac{n+1}{4} + 512 \times \frac{n}{n+2}

となります。

n12345右辺287.8344.3378.2400.9417.2\begin{array}{c|ccccc} n & 1 & 2 & 3 & 4 & 5 \\ \hline 右辺 & 287.8 & 344.3 & 378.2 & 400.9 & 417.2 \end{array}

左辺の指数部が k=350k = 350 であることを考えると、不等式が成立するのは n3n \ge 3 のときになります。

実際に nn を可変にして計算してみます。

for n in range(1, 6):
    tmp_matrix = [[0 for _ in range(n+2)] for _ in range(n+2)]
    for i in range(n):
        tmp_matrix[i][i] = p
    for i in range(n):
        tmp_matrix[n][i] = xs[i]
    for i in range(n):
        tmp_matrix[n+1][i] = zs[i]
    tmp_matrix[n][n] = 1
    tmp_matrix[n+1][n+1] = 2**k
    L = Matrix(ZZ, tmp_matrix)
    tmp = hex(abs(L.LLL()[0][n]))[2:]
    if len(tmp) % 2 == 1:
        tmp = tmp.zfill(len(tmp)+1)
    flag = bytes.fromhex(tmp)
    print(n, flag)
1 b'f8\x17u\x8c\xa5\xd5\xbfJTd&\xa8O"rU\x82\xd7\xef\x94\xf8o\xf2\xde\xd4\xef\xf1\xd12i\xe5'
2 b"\x06\x81\x9f-\xbfo\x8d,\x07\x15_QTq\xa6\xb0\x0f\xe2KC\x92i\xda'7\xa8\xcd\xd5\xb3\xd8\x90VIu\xb0\xf1\xb3b\xeb\xe0\x826\x83"
3 b'HarekazeCTF{H0chmu7_k0mm7_v0r_d3m_F411}'
4 b'HarekazeCTF{H0chmu7_k0mm7_v0r_d3m_F411}'
5 b'HarekazeCTF{H0chmu7_k0mm7_v0r_d3m_F411}'

となり、実際 n<3n < 3 では答えが正しく求まっていないことがわかります。 この問題に限って言えば、 n=3n=3 でも十分に答えを求められたというわけです。

まとめ

Crypto 問で SVP に落とし込めるような問題は、

  • 次元がそこまで大きくない
  • Given な値の線形和で、それらの値より十分に小さい非自明なベクトルを書き表せることが保証されている

といった特徴を持っています。必要なときは最短ベクトルが満たす不等式を考え、格子基底行列に適宜定数を与えることで柔軟に問題を解くことができます。