RSA 暗号を理解して有名な攻撃を試してみる
この記事は暗号を理解して解読できるようになろうというシリーズの一部です。シリーズの一覧は次のようになっています。
はじめに
すぐそこに秘密の世界があります。この記事を読んでいるなら、あなたは暗号化された通信を通してサーバーから電気線を通り、遥々その端末に届いている文字を読んでいます。
こんなことを考えてみてほしい。機器から暗号を使って通信をしていますが、私達が触れていないだけで暗号は誰でも解けるものであり、近くを通った人が自分の通信を盗聴しているかもしれない。ゾクッとしませんか。
そんなことが2017年に起こりました。ROCA攻撃と呼ばれるものです。暗号の中でも最も世界で活躍していると言われる「RSA暗号」が簡単に解けてしまうというものです。
その「RSA暗号」の本当の世界を覗いてみましょう。
ここでは実際にスクリプトを書いて攻撃していきます!攻撃したことない人はコードを書きながら勉強でき、CTFerにはライブラリ保管庫として使ってほしいと思って書いてます。
数学については高校数学のみで十分だと思います。少しばかり大学数学もありますが知らなくても読める程度にはなっていると思います。
RSA暗号
前回の記事では公開鍵暗号を取り扱いました。
公開鍵暗号
暗号化と復号で 別の鍵 を使い、暗号化で使う鍵を 公開 する方式
ex.) RSA暗号, 楕円曲線暗号 など
RSA 暗号 (Rivest-Shamir-Adleman encryption) はその公開鍵暗号の 1 種で 素因数分解 の難しさをベースにした暗号です。
どんな数も素数の積となります。
数から計算して素数の積にすることを素因数分解といいます。では次の数を素因数分解してみてください。
かなり時間が掛かるんじゃないでしょうか。この難しさが RSA 解読を困難にしています。RSA で使われる実際の数はもっと大きくて例えば次のような数を扱います。
ちなみに上の答えは次の素因数分解の結果を集めたデータベースに入れると分かります。
素因数分解が分かりましたが RSA 暗号ではどのように仕組まれているのでしょうか。大ざっぱに書くと次のような手順です。
RSA 暗号
大きな素数を用意して p, q を作る。このとき平文を N = pq 、暗号文を m とすると次のように計算できる。 c \begin{aligned} c &= m^e &\pmod N \\ m &= c^{e^{-1}} &\pmod N \end{aligned} ただし
は予め決めておいた定数とする。 e
これが RSA 暗号です。つまり
実際にやってみましょう!
先程の
ただし
となります。おお!暗号化して復号すると同じ数になりました。ちゃんと暗号化と復号で逆の操作になっていそうです。
ちなみに
このように計算できます。
実装
計算方法が分かったので実装できそうです。
具体的には次の手順で暗号化された通信を実現します。
- Alice が大きな素数
を生成し、Bob に公開鍵p, q を渡すN, e - Bob は公開鍵を用いて平文を暗号化
- Bob から Alice へ暗号文を送る
- Alice は秘密鍵
を用いてp, q を計算して復号し、平文を得るd
ここで
このようにして送っている最中に盗聴されても秘密鍵がなければ解読できず、安全な通信ができます。またこれ自体は一方向通信ですが、逆も同様に行えば双方向通信もできます。
実装するにはそれぞれの具体的なパラメータについて知っておきましょう。
0x10001 = 65537
が使われています。攻撃されないような丁度よい大きさの数であり、かつ暗号化する際にバイナリ法を用いると素早く計算できる数であるという理由が挙げられます。ちなみに 65537 は現在見つかっているフェルマー素数の中で最も大きな数ですね。
より詳細は RFC 8017 を参照してください。
from Crypto.Util.number import getPrime, long_to_bytes, bytes_to_long
p = getPrime(512)
q = getPrime(512)
N = p * q
phi = (p - 1) * (q - 1)
e = 0x10001
d = pow(e, -1, phi)
def encrypt(plaintext):
m = bytes_to_long(plaintext)
c = pow(m, e, N)
cipher = long_to_bytes(c)
return cipher
def decrypt(cipher):
c = bytes_to_long(cipher)
m = pow(c, d, N)
plaintext = long_to_bytes(m)
return plaintext
cipher = encrypt(b"This is RSA")
print(cipher)
plaintext = decrypt(cipher)
print(plaintext)
assert(plaintext == b"This is RSA")
ライブラリの説明
pycryptodomeライブラリの関数 | 説明 |
---|---|
getPrime(n) |
n bit長のランダムな素数を生成 |
bytes_to_long(bytes) |
バイト文字列をASCIIとしてデコードし数に変換 |
long_to_bytes(n) |
数をASCIIとしてエンコードしバイト文字列に変換 |
安全性
計算機代数において多項式時間で解けることを計算可能と言います。ぱっと見、自然なのですが例えば
- 1 つでも計算困難な処理が入れば全体として計算困難
- 計算可能な処理で構成されていればどんなに色々呼び出して複雑にしても計算可能
そして RSA 暗号の計算困難性は素因数分解の計算困難性と等しいです。
Prop.
素因数分解が計算可能ならば RSA 暗号の解読も計算可能である。
Proof.
素因数分解が解けるから
逆に素因数分解が計算困難なれば RSA 暗号の解読が計算困難であることの証明はありますが、非常に長くなるので他の文献に任せます。一般に計算困難性を示すのは難しい問題で未解決問題も多いです。代表例だと判定が計算可能なら解くのも計算可能という主張の P = NP 問題はミレニアム検証問題と言われ、解けたら賞金が出ます。肯定的に解決すると全ての暗号は計算可能という主張にもなります。
RSA-CRT
RSA の復号をする際に
全体の計算量としては同じですが全て小さな値のみで計算できるので多倍長整数の処理分だけ高速化が図れます。
そして
RSA-OAEP
メッセージが改ざんされずに届けられていること(完全性)を確認するのにパディングは用いられます。RSA では RFC 8017: PKCS #1 V2.2 (RSA Cryptography Specifications Version 2.2) の主に次の 3 つのパディングが使われます。
- PKCS#1 v1.5 (Public-Key Cryptography Standards#1 v1.5)
- 簡単なパディング形式:
0002<random>00<hashprefix><message>
- 簡単なパディング形式:
- OAEP (Optimal Asymmetric Encryption Padding)
- 攻撃に強いパディング (IND-CCA2) 証明は OAEP Reconsidered - Victor Shoup にある
- PSS (Probabilistic Signature Scheme)
- 署名でよく使われる確率的な検証を行うパディング形式
このようなパディングを用いた RSA を RSA-[パディング名] などと呼んだりします。
素因数分解
計算機で解くことの難しい部類 NP 完全の問題です。素因数分解したい数
試し割り法を基本にして
試し割り法
愚直に素数を小さい順に割っていく方法です。大体の場合これで十分速いです。
-
をN 以上2 以下の整数で下から順に割れるだけ割り続け、割った数\sqrt{N} とその回数p を記録するe - 最後に残った
がN ではないならば記録する1
この計算量は
def trial_division(N: int) -> list[tuple[int, int]]:
res: list[tuple[int, int]] = []
for p in range(2, N):
if p * p > N:
break
if N % p != 0:
continue
e = 0
while N % p == 0:
e += 1
N //= p
res.append((p, e))
res.append((N, 1))
return res
ρ 法
Pollard-Prop. 誕生日のパラドックス
誕生日が同じ 2 人を見つけたいときに必要な人数の期待値は約である。 \sqrt{365\pi/2}
コンピュータ上ではランダムな数を集める為に擬似的な乱数を生成する関数
計算量は
from collections.abc import Callable
from math import gcd
def pollard_rho(N: int) -> int:
f: Callable[[int], int] = lambda x: (x * x + 1) % N
x = y = 2
d = 1
while d == 1 or d == N:
x = f(x)
y = f(f(y))
d = gcd(abs(x - y), N)
return d
p-1 法
Pollard の Prop.
がある素因数 N をもつとき p が k の倍数であれば p-1 は a^k - 1 \pmod N の倍数となる。 p
Proof.
フェルマーの小定理より
もちろん
この
from math import gcd, log
def p_1(N: int) -> int:
B = 1000000
primes = eratosthenes(B)
M = 3
for p in primes:
M *= pow(M, p ** int(log(B, p)), N)
return gcd(M - 1, N)
p+1 法
Hugh Williams の Prop.
が k の倍数であれば Lucas 数列 p+1 に対し、 u_i, v_i は u_k の倍数となる。ただし Lucas 数列は次のように定義される。 p \begin{aligned} u_0 & = 0, u_1 = 1, u_{n+1} = au_n - bu_{n-1} \\ v_0 & = 2, v_1 = a, v_{n+1} = av_n - bv_{n-1} \end{aligned}
from itertools import count
def mlucas(v, a, n):
v1 = v
v2 = (v**2 - 2) % n
for bit in bin(a)[3:]:
if bit == "0":
v1 = (v1**2 - 2) % n
v2 = (v1*v2 - v) % n
else:
v1 = (v1*v2 - v) % n
v2 = (v2**2 - 2) % n
return v1
def pp1(n):
for v in count(3):
for p in Primes():
e = int(log(int(sqrt(n)), p)) + 1
if e == 0:
break
for _ in range(e):
v = mlucas(v, p, n)
g = gcd(v - 2, n)
if 1 < g < n:
return g
if g == n:
break
楕円曲線法
一般に有効な素因数分解法です。
Prop.
が k の倍数であれば \#E/(\mathbb{Z}/N\mathbb{Z}) となる。このとき内部計算時に kP = \mathcal{O} が x_1 - x_2 の倍数となる。 p
計算量は準指数時間
N = 1715761513
E = EllipticCurve(Zmod(N), [3, -13])
P = E(2, 1)
try:
LCM([2..60]) * P
except ZeroDivisionError as e:
print(e)
Fermat 法
初期値を
他の比率のときは
from math import floor, sqrt
def fermat(N: int, a: int = 1, b: int = 1) -> tuple[int, int]:
abN = a * b * N
x = floor(sqrt(abN - 1)) + 1
y = floor(sqrt(x * x - abN))
while True:
w = x * x - abN - y * y
if w == 0:
break
elif w > 0:
y += 1
else:
x += 1
if (x + y) % a == 0:
return ((x - y) // b, (x + y) // a)
else:
return ((x - y) // a, (x + y) // b)
また、
二次ふるい法 (QS; Quadratic Sieve)
- ある範囲
の中で\sqrt{N} - \epsilon < x_i < \sqrt{N} + \epsilon がx_i^2 - N -smooth となるような数をいくつか取ってくるB -
を素因数分解し、いくつかの積x_i^2 - N がちょうど平方数となるように選択する(x_i^2 - N)\cdots(x_j^2 - N) - その平方数を
とするとy^2 となりx^2 = y^2 \pmod N のどちらかはx\pm y の倍数となるp
なんかしらんけど計算量は
一般数体ふるい法 (GNFS; General Number Field Sieve)
計算量は
Shor のアルゴリズム
量子アルゴリズムについてはちょっとだけ齧っているだけなので詳しい解説は他の良い資料に任せます。
本質的には群の位数を見つけることで素因数分解します。
位数発見問題
の位数 a つまり n となる最小の a^n = 1 \pmod N を見つける問題。 n
アニーリング計算
アニーリング計算は多変数多項式を最小にするような解を与えるような量子アルゴリズムです。素因数分解するアニーリング計算は素朴法と筆算法などがあり、それを紹介します。
素朴法
多変数関数
項数は比較的少ないが係数が大きくなりがちな方法です。
筆算法
筆算法は
素朴法に比べ、変数は多くなりますが係数を小さくすることができます。また素数が奇数であることや 2 進数による条件などから変数を減らすことができます。
素因数分解データベース
素因数分解の結果をデータベースとして保管しているサイトがあります。実戦ではこれが便利です。
\gcd
共通の素因数を持つ数と 何かしらの情報から脆弱性を突き、共通の素因数を取得できたら
ここまで話したけれども実践的に CTF で使われるのは「素因数分解 DB」か「共通の素因数を持つ数と
攻撃
RSA 暗号は素因数分解の困難性が安全性の根拠ですが、うまく実装しないと素因数分解を解かなくても攻撃が出来てしまいます。
RSA 暗号ではこのような攻撃が発見されてきました。
アンチケース | 攻撃名 | 方法 |
---|---|---|
公開鍵 |
Low Public Exponent Attack | 整数上の |
秘密鍵 |
Wiener's Attack / Boneh-Durfee Attack | 近似分数から見積もる, Coppersmith Method |
同一の平文を同一の |
Common Modulus Attack |
|
同一の平文を異なる |
Håstad's Broadcast Attack | 中国剰余定理 |
同一の平文を同一の |
Small Common Private Exponent Attack | Coppersmith Method |
RSA-CRT にメモリ書き換えのバグがあってはならない | RSA-CRT Fault Attack | 秘密鍵が書き換えれると平文の差分が |
特定の暗号以外の暗号文を復号した結果を知られてはならない | 適応的選択暗号文攻撃 |
|
エラーの内容を知られてはならない | Bleichenbacher's Attack | 二分探索など |
平文を部分的にでも知られてはならない | LSB Decryption Oracle Attack など | 二分探索 |
秘密鍵を部分的にでも知られてはならない | Partial Key Exposure Attack | Coppersmith Method |
上位ビットが共通する二つの平文に対する暗号文を知られてはいけない | Franklin-Reiter Related Message Attack / Coppersmith's Short pad Attack | 最大公約式 |
e が小さすぎてはいけない (Low Public Exponent Attack)
累乗根はニュートン法と呼ばれる近似法を用いると高速に求められます。これはPythonパッケージのgmpy2で実装されているのでそれを使います。
ニュートン法
関数に対して f(x) となる解 f(x) = 0 をテイラー展開の 1 次項までを用いて近似する。 x x_{n+1} = x_n - \frac{f(x_n)}{f'(x_n)}
Proof.
となり、
となる。
これに
が近似する漸化式となる。
import gmpy2
m = gmpy2.iroot(c, e)[0]
d が小さすぎてはいけない (Wiener's Attack)
秘密鍵
ある分数についてユークリッドの互除法を用いて連分数展開し、適当な場所で打ち切って再構成することで近似分数を作ることが出来ます。
連分数による近似分数の生成
\frac{a}{b} \approx q_0 + \cfrac{1}{q_1 + \cfrac{1}{q_2 + \cfrac{1}{\ddots \cfrac{}{q_{m-1} + \cfrac{1}{q_m}}}}} = \frac{\alpha_m}{\beta_m} \\
については次の漸化式を用いて計算できます。形式的に数列の q_i, \alpha_i, \beta_i 番目も定義することで分かりやすく計算できます。 -1, -2 \begin{aligned} r_{-2} &= a & \alpha_{-2} &= 0 &\beta_{-2} &= 1 \\ r_{-1} &= b & \alpha_{-1} &= 1 &\beta_{-1} &= 0 \\ r_{i-2} \div r_{i-1} &= q_{i} \cdots r_{i} & \alpha_i &= q_i \alpha_{i−1} + \alpha_{i−2} &\beta_i &= q_i \beta_{i−1}+\beta_{i−2} \\ \end{aligned}
Wiener's Attack
のとき連分数を用いて近似分数を用いて解ける。 d < N^{\frac{1}{4}}/3
まず次のように変形します。
そして
この攻撃は以下のように
import gmpy2
def WienersAttack(n, e):
r0, r1 = e, n
k0, k1 = 0, 1
d0, d1 = 1, 0
i = 0
while r1 != 0:
q = r0 // r1
r0, r1 = r1, r0 % r1
k0, k1 = k1, q*k1 + k0
d0, d1 = d1, q*d1 + d0
if i % 2 == 0:
k = k1 + k0
d = d1 + d0
else:
k = k1
d = d1
i += 1
if k == 0 or (e * d - 1) % k != 0:
continue
s = n - (e * d - 1) // k + 1
D = s*s - 4*n
sD = gmpy2.isqrt(D)
if D > 0 and sD * sD == D:
return d
return -1
さらに Wiener's Attack より強い攻撃として Boneh-Durfee Attack があります。
Boneh-Durfee Attack
のとき Coppersmith Method を用いて解ける。 d < N^{0.292}
まず以下のように変形します。
この方程式について
SageMath は標準で多変数多項式の Coppersmith Method は実装されていないので世界の Crypto プレイヤー defund さんによる実装 defund/coppersmith を用いて攻撃することが多いです。
load('coppersmith.sage')
def boneh_durfee(N, e):
bounds = (floor(N^.25), 2^1024)
P.<k, s> = Zmod(e)[]
f = 2*k*((N+1)//2 - s) + 1
print(small_roots(f, bounds, m=3, d=4))
同一の平文を異なるnで暗号化した暗号文を与えてはいけない (Håstad's Broadcast Attack)
同一の平文を異なる
これらの暗号文を中国剰余定理によって持ち上げます。
持ち上げた先で
同一の平文を異なるeで暗号化した暗号文を与えてはいけない (Common Modulus Attack)
異なる
これによって
任意の暗号文を復号した結果を知られてはいけない (適応的選択暗号文攻撃)
任意の暗号を復号した結果を知っているとき、ある暗号文の復号結果を防がれていたとしても他の暗号を送ることで解読できます。
Prop.
を復号した結果は n^ec となる。また上位ビットが固定されたパディングは不等式で表現できる。 nm \mathcal{D}(n^ec) = nm \pmod N
これに対する防御方法として平文にパディングを施し、復号した際にパディング形式が違うときは相手に渡さないようにするという方法があります。これによって正当な暗号文しか受け入れず、適応的選択暗号文攻撃を防げます。
パディングによるエラー内容を知られてはいけない (Bleichenbacher's Attack)
これについてパディングが合っているかどうかを相手に送ってしまうと Padding Oracle Attack で攻撃でき、PKCS #1 v1.5では200万程度送ると平文が読めてしまいます。
対して Padding Oracle Attack で破られないようなパディング形式は IND-CCA2 と呼び、例えば RSA-OAEP はそうです。
暗号文を復号した結果の偶奇を知られてはいけない (LSB Decryption Oracle Attack)
全てが分かっていなくとも偶奇さえ分かれば任意の暗号文を復号できるという攻撃です。
まず平文
となります。この上下を判別出来れば平文となり得る範囲を半分にすることができます。ここで最下位ビットについてよく見ると値の偶奇を考えることで上のとき
この判別を
より一般化して考えると平文に関する情報を一部でも与える仕組みがあれば、ガチャガチャすることで全ての内容が分かってしまうという訳です。
- Padding Oracle Attack ... パディングによる数値の範囲の情報
- LSB Decryption Oracle Attack ...
の情報m\bmod 2
RSA-CRT にバグがあってはならない (RSA-CRT Fault Attack)
RSAの復号をする際に
これより下の値を秘密鍵として持つことになります。
しかし
これより平文
これより元々の平文と書き換えられた平文の差が素数の倍数となり、解くことができます。
秘密鍵は部分的にでも知られてはいけない (Partial Key Exposure Attack)
秘密鍵を部分的に知っていさえいれば Coppersmith Method を用いて解けてしまいます。Coppersmith Method は剰余の多項式において小さな解を求めるアルゴリズムです。詳細は次の記事で紹介しています。
ここでは詳しいことは考えず SageMath の small_roots(X, beta)
を使って多項式の解が求まると思えばいいです。
p, q のどちらかを n/4 ビット程度知っているとき
例えば
これに対して法の数を
def partial_p_lower(p_lower: int, k: int, N: int) -> int:
PR.<x> = Zmod(N)[]
f = 2^k*x + p_lower
f = f.monic()
roots = f.small_roots(X=2^(N.nbits()//2-k), beta=0.3)
for x0 in roots:
return gcd(2^k*x0 + p_lower, N)
return 1
def partial_p_upper(p_upper: int, k: int, N: int) -> int:
PR.<x> = Zmod(N)[]
f = x + p_upper
roots = f.small_roots(X=2^(floor((n.nbits() / 4) * 6/7)), beta=0.3)
for x0 in roots:
return x0 + p_upper
return 1
d を n/4 ビット程度知っているとき
上位ビットの場合
まず
第三式について
下位ビットの場合
まずは
d を n/4 ビット程度知っているとき
RSA-CRT の秘密鍵 RSA-CRT についても次のように式を立てられるので同様に解けます。
m を (1-1/e)n ビット程度知っているとき
平文 次のように式を立てられますが、次数が大きいのである程度知っていないと解けません。
上で紹介した攻撃以外も知りたければ次の資料を読むことををおすすめします。
上位ビットが共通する二つの平文に対する暗号文を知られてはいけない (Franklin-Reiter Related Message Attack)
Franklin-Reiter Related Message Attack
2 つの平文について代数的な関係 m_1, m_2 があるとき、それらの暗号文 m_2 = f(m_1) から平文を求められる。 c_1, c_2
次のように方程式
このように同じ解を持つ方程式は
Coppersmith's Short Pad Attack
特にに対して小さな値 m_1, m_2 \approx N を用いて r \approx O(N^{1/e^2}) という関係があるとき、 m_2 = m_1 + r が小さければ全探索して最大公約元を取ればいいが r が全探索できないほど大きいときにも実は解けるというのが Coppersmith's Short Pad Attack である。ただし r は自然対数の底である。 e
まず次のように方程式
def franklinReiter(n, e, r, c1, c2):
R.<x> = Zmod(n)[]
f1 = x^e - c1
f2 = (x + r)^e - c2
return - polygcd(f1, f2).coefficients()[0]
def polygcd(a, b):
if(b == 0):
return a.monic()
else:
return polygcd(b, a % b)
def CoppersmithShortPadAttack(e, n, C1, C2, eps=1/30):
P.<x,y> = ZZ[]
g1 = x^e - C1
g2 = (x + y)^e - C2
res = g1.resultant(g2)
Py.<y> = Zmod(n)[]
res = res.univariate_polynomial()
res = res.change_ring(Py).subs(y=y)
res = res.monic()
kbits = n.nbits()//(2*e*e)
diff = res.small_roots(X=2^kbits, epsilon=eps)
return franklinReiter(n, e, diff[0], C1, C2)
The Return of Coppersmith's Attack
鍵生成アルゴリズムが仕様に沿わないと暗号学的に脆弱となる可能性があります。主要な暗号ハードウェアメーカで使われているライブラリ RSALib では Smooth number
これは
RSA | |
---|---|
512bit RSA | |
1024bit RSA | |
2048bit RSA |
\phi(N) = se^n と表されるとき e の逆元が取れない
乗法群の位数が
こういうときはまず暗号文
素因数分解ベースの暗号
Paillier 暗号
Paillier 暗号は 1999 年に考案された稀に見る加法準同型性を持つ暗号です (元論文)。ベースの環は
適当に
となる。乱数
次のように復号できる。
Rabin 暗号
1979 年に考案された選択平文攻撃で解読することと素因数分解問題を解くことが等価であることが初めて証明された暗号です。
暗号文を次のように生成する。
復号は次の 2 次方程式を解けばよいです。
これは素因数分解できていれば次を解くのは簡単です。
この解を CRT して復号ができます。
まとめ
今回はRSA暗号に絞りましたが実際の CTF はもっと広くて自由です! RSA に似てるけど解法が違う暗号やぱっと見 RSA ではなさそうな暗号も RSA に帰着させることが出来たりする暗号など様々あります。それでもここで扱った概念はそれらの基礎になります。
あなたも CTF に出て暗号の世界を堪能してみませんか。
参考文献
- RSA暗号運用でやってはいけない n のこと
- RSA暗号攻撃で他でも使える n のこと
- CTF crypto 逆引き
- Recovering cryptographic keys from partial information, by example
- Twenty Years of Attacks on the RSA Cryptosystem
- p - 1 ≡ 0 (mod e) のときの RSA 復号方法
- SageMathを使ってCoppersmith's Attackをやってみる
- RTACTF
- Crypto Challenge
- They Were Eleven - BSides Ahmedabad CTF 2021 author's writeup
- The Return of Coppersmith's Attack:Practical Factorization of Widely Used RSA Moduli (acmccs.github.io)
- RSAに対する適応的選択暗号文攻撃とパディング方式
Discussion