CMRGを予測する
序文 §
いつものupsolveのネタが尽きてきたので重めの問題(pbctf 2021 - Yet Another PRNG)をやろうとしたら、参考となっている論文が面白かったので紹介するついでにPoCを書きます
CMRGと問題設定 §
今回解析するRNGはCMRG("Combined Multiple Recursive Generators")というやつで、論文中では次のように状態の更新と出力が定義されている。
問題設定として、は既知であり、ここからある程度(今回の方法による最低値は7個)の出力を得てから、後続の出力を予測する。
CRT §
上記の通り、CMRGは実質的に2つの線形なRNGを組み合わせている。もし、が互いに素(大抵は周期を伸ばすためにが素数が使われるはずなので満たされる)であれば、中国人剰余定理を使って次のようなを求めて2つのRNGを1つのものとして扱う事が出来る
このに対して、とすれば次が成り立つ。
格子に落とす式 §
上記で定義したとそれに関する式に対してをで表すことが出来れば、を法としている式に対して、が現れ、これは式中の項の中で比較的小さい値となるから格子の問題に落とすことが出来る予感がする。というわけで試みる。
ここでと定義する。通常の出力との違いはで法を取っているかいないかである。
出力の式を見るととなっているが、とが近いと仮定すれば、が成り立つので、としてよい。よって、は出力に対して2択であり、実際得た出力に対して全探索することでどこかで正しいに当たる。よって、以下ではを引き当てた場合を仮定する。
の定義から、となる整数が存在する。中辺と右辺を変形すると次が成り立つ
更にで法をとると、が成り立つから、について解くととなる。論文に従って、とおいて、とする。
よって、このようなを用いれば次が成り立つ
この式中で以外の項の大きさはと同程度であるが、これらは程度であるから有意に小さい。よってLLL等の基底簡約アルゴリズムで短いベクトルを求めればその中にが現れるような格子を構成出来る可能性が見えてくる。
LLLで倒す §
初期状態をとして7つの出力を得る。簡単のためそれぞれのに対してを当てられたとする(よっては全て計算できる)。
この時、先程の式からはを変数として表すことが出来る。具体的には次のようになる。
ここで、は定数項を全部足したものとする。また、に関しては次のような関係がある。
同様にしてはで表すことが出来るが、がで表すことが出来たのでも同様である。
このようにしてはいずれもの線形多項式で表すことが出来るので次のようにおく。
法を外して、商をとおくと次のようになる
先に述べたように、が短いベクトルの成分として現れるような格子を組んで簡約し、これらを求めることを考える。今回は次のような格子を組んだ。
これに左からを掛けると、が現れる。
この格子の体積はであるので、LLLで出てくる基底の大きさは(だいたい)より小さくなり、のノルムがだいたいこのぐらいなので出てくれると期待出来る。
が求められれば、からを求めることが出来るので以降の出力を完全に予測出来る。
係数と定数項を求める §
各に対して、の係数と定数項を手計算で求めようとすると骨が折れすぎるので次のスクリプトで求めた。
A = var("A")
B = var("B")
C = var("C")
Ds = [var(f"D{i}") for i in range(4)]
vars = [var(f"v{i}") for i in range(3)]
for i in range(3, 3 + 4):
Ds.append(var(f"D{i}"))
vars.append(A*vars[-1] + B*vars[-2] + C*vars[-3] + Ds[-1])
for i, v in enumerate(vars):
if i < 3:
continue
c_v0 = v.list(vars[0])[1]
c_v1 = v.list(vars[1])[1]
c_v2 = v.list(vars[2])[1]
constant = v.list(vars[0])[0].list(vars[1])[0].list(vars[2])[0]
coeffs_dump = f"""{i}:
v0_c = {c_v0}
v1_c = {c_v1}
v2_c = {c_v2}
constant = {constant}"""
print(coeffs_dump)
これを実行すると次のような結果になり、各係数が得られる
3:
v0_c = C
v1_c = B
v2_c = A
constant = D3
4:
v0_c = A*C
v1_c = A*B + C
v2_c = A^2 + B
constant = A*D3 + D4
5:
v0_c = A^2*C + B*C
v1_c = A^2*B + B^2 + A*C
v2_c = A^3 + 2*A*B + C
constant = A^2*D3 + B*D3 + A*D4 + D5
6:
v0_c = A^3*C + 2*A*B*C + C^2
v1_c = A^3*B + 2*A*B^2 + A^2*C + 2*B*C
v2_c = A^4 + 3*A^2*B + B^2 + 2*A*C
constant = A^3*D3 + 2*A*B*D3 + A^2*D4 + C*D3 + B*D4 + A*D5 + D6
余談だが、参考にした論文(参考文献に記載)では、これと似たような格子を組んで簡約しているようだが、論文に掲載されている格子は計算ミスで値が誤っているようである。
Code §
PRNG内で幾つかのチェックをしており見にくいが、最終的に7つのと既知の値のみからを復元している
# based on https://eprint.iacr.org/2021/1204.pdf
import random
# ref and parameter stolen from: http://www.secmem.org/blog/2021/10/24/Breaking-Combined-Multiple-Recursive-Generators/
class PRNG:
def __init__(self) -> None:
self.m1 = 2**32 - 107
self.m2 = 2**32 - 5
self.N = self.m1 * self.m2
assert is_prime(self.m1)
assert is_prime(self.m2)
self.a1 = [random.getrandbits(32) for _ in range(3)]
self.a2 = [random.getrandbits(32) for _ in range(3)]
self.__x1 = [random.getrandbits(32) for _ in range(3)]
self.__x2 = [random.getrandbits(32) for _ in range(3)]
# debug parameters
# CRT
self.A = crt([self.a1[0], self.a2[0]], [self.m1, self.m2])
self.B = crt([self.a1[1], self.a2[1]], [self.m1, self.m2])
self.C = crt([self.a1[2], self.a2[2]], [self.m1, self.m2])
self.Ds = []
self.__answer1 = []
self.__answer2 = []
self.u = power_mod(self.m1, -1, self.m2)
# z' in paper
# self.__z = [x - y for x,y in zip(self.__x1, self.__x2)]
# self.__k = [-z * self.u % self.m2 for z in self.__z]
self.__z = []
self.__k = []
def next(self, i=None) -> int:
new_x1 = sum([x * y for x, y in zip(reversed(self.a1), self.__x1)]) % self.m1
new_x2 = sum([x * y for x, y in zip(reversed(self.a2), self.__x2)]) % self.m2
new_z = new_x1 - new_x2
new_k = -new_z * self.u % self.m2
# t1 = new_k * self.m1 + new_x1
# t2 = self.A * (self.__k[2] * self.m1 + self.__x1[2])
# t3 = self.B * (self.__k[1] * self.m1 + self.__x1[1])
# t4 = self.C * (self.__k[0] * self.m1 + self.__x1[0])
# P = (t1 - t2 - t3 - t4) % (self.m1 * self.m2)
if i is not None and i > 2:
D = (-new_k + self.A * self.__k[-1] + self.B * self.__k[-2] +self.C * self.__k[-3]) * self.m1 % (self.N)
self.Ds.append(D)
P = (self.A * self.__x1[2] + self.B * self.__x1[1] + self.C * self.__x1[0] + D) % (self.N)
assert P == new_x1
# rough check
if i in [3, 4, 5, 6]:
v0, v1, v2 = self.__answer1[:3]
A, B, C = self.A, self.B, self.C
Ds = self.Ds
if i == 3:
v0_c = C
v1_c = B
v2_c = A
constant = self.Ds[0]
elif i == 4:
v0_c = A * C
v1_c = A * B + C
v2_c = A**2 + B
constant = A * self.Ds[0] + self.Ds[1]
elif i == 5:
v0_c = A^2*C + B*C
v1_c = A^2*B + B^2 + A*C
v2_c = A^3 + 2*A*B + C
constant = A^2*Ds[0] + B*Ds[0] + A*Ds[1] + Ds[2]
elif i == 6:
v0_c = A^3*C + 2*A*B*C + C^2
v1_c = A^3*B + 2*A*B^2 + A^2*C + 2*B*C
v2_c = A^4 + 3*A^2*B + B^2 + 2*A*C
constant = A^3*Ds[0] + 2*A*B*Ds[0] + A^2*Ds[1] + C*Ds[0] + B*Ds[1] + A*Ds[2] + Ds[3]
rhs = (v0_c * v0 + v1_c * v1 + v2_c * v2 + constant) % self.N
assert rhs == new_x1
self.__z.append(new_z)
self.__k.append(new_k)
self.__answer1.append(new_x1)
self.__answer2.append(new_x2)
self.__x1 = self.__x1[1:] + [new_x1]
self.__x2 = self.__x2[1:] + [new_x2]
# assumption: perfect guess z'
return new_z
# return (new_x1 - new_x2) % self.m1
def check(self, zs, ks, Ds):
res1 = self.__z == zs
res2 = self.__k == ks
res3 = self.Ds == Ds
return (res1, res2, res3)
def get_answer(self):
return self.__answer1, self.__answer2
# cheating (gueesing z' is success)
prng = PRNG()
# public parameters
m1 = prng.m1
m2 = prng.m2
N = m1 * m2
a1 = prng.a1
a2 = prng.a2
u = power_mod(m1, -1, m2)
print("=============== Exploit ===============")
A = crt([a1[0], a2[0]], [m1, m2])
B = crt([a1[1], a2[1]], [m1, m2])
C = crt([a1[2], a2[2]], [m1, m2])
Ds = []
ks = []
zs = []
for i in range(10):
z = prng.next(i)
k = -z * u % m2
if i >= 3:
D = (-k + A * ks[-1] + B * ks[-2] + C * ks[-3]) * m1 % N
Ds.append(D)
zs.append(z)
ks.append(k)
# check
res = all(prng.check(zs, ks, Ds))
assert res
# calculation x0, x1, x2
size = 8
mat = [
[0 for _ in range(size)] for _ in range(size)
]
for i in range(3):
mat[i][i] = 1
mat[3][3] = 2^32
for i in range(4):
mat[4+i][4+i] = N
# v0_c, v1_c, v2_c, constant
polys = [
[C, B, A, Ds[0]],
[A * C, A * B + C, A**2 + B, A * Ds[0] + Ds[1]],
[A^2*C + B*C, A^2*B + B^2 + A*C, A^3 + 2*A*B + C, A^2*Ds[0] + B*Ds[0] + A*Ds[1] + Ds[2]],
[A^3*C + 2*A*B*C + C^2, A^3*B + 2*A*B^2 + A^2*C + 2*B*C, A^4 + 3*A^2*B + B^2 + 2*A*C, A^3*Ds[0] + 2*A*B*Ds[0] + A^2*Ds[1] + C*Ds[0] + B*Ds[1] + A*Ds[2] + Ds[3]]
]
def dump_mat(m):
for row in m:
print(row)
for i, poly in enumerate(polys):
for j, v in enumerate(poly):
mat[j][i+4] = v % N
M = matrix(ZZ, mat)
for b in M.LLL():
answer = []
_ys = []
if abs(b[3]) == 2**32:
for x in b[:3]:
answer.append(abs(x))
for x, z in zip(answer, zs):
_ys.append(x - z)
x = sum([_x * _a for _x, _a in zip(answer, reversed(a1))]) % m1
y = sum([_y * _b for _y, _b in zip(_ys, reversed(a2))]) % m2
if x - y == zs[3]:
print(answer, _ys)
break
true_x, true_y = prng.get_answer()
print(f"[{answer == true_x[:3]}] answer: {true_x[:3]}")
print(f"[{_ys == true_y[:3]}] answer: {true_y[:3]}")
Reference §
- Breaking Combined Multiple Recursive Generators: pbctf 2021 - Yet Another PRNGの解説
- Attacks on Pseudo Random Number Generators Hiding a Linear Structure: ↑で取り上げられていた論文、本記事はこれをベースにして格子の使い方をやや変えている