BND-CTF: RSA Key Generation
Der BND hat mal wieder einen öffentlichen Einstellungstest, dem wir uns erfreuen dürfen. Ideal, um sich mal etwas in CTF-Writeups zu probieren!
Aus der Kategorie Cryptography schauen wir uns mal die (einzige) Challenge an: Analysieren einer Backdoor in einem RSA-Schlüsselerzeugungsalgorithmus.
Folgender Code wurde uns bereitgestellt:
SageMath-Code
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
### Excerpt from utils.py (used to encode / decode the message)
def encode(msg):
"""
Convert a message to a number.
Example:
>>> hex(encode(b'ABCD'))
'0x41424344'
"""
return int.from_bytes(msg, byteorder='big')
def decode(msg):
"""
Convert a number back to a message.
Example:
>>> decode(0x41424344)
b'ABCD'
"""
return int(msg).to_bytes(length=len(hex(msg))//2-1, byteorder='big')
### Excerpt from RSA.py
x = 0xbb31781a2436fd6833597b61f91b94fba8cc5be702c7084de28625d96823102daf48dd84244fe41d180452a900388d1666ff59981f0912c6640977684c20bcfdcbf365dfcb68c0c5a9fd02576134a0e94ab9e20bbacffb4df5c9c27ae7f5022f6609aefeb9f5249387925ad13ce80a13
def rsa_keygen():
def a(x, y):
z = 1
while True:
if is_prime(x*z + y):
return x*z + y
z += 1
p = a(x, random_prime(2^128, None, 2^127))
q = a(x, random_prime(2^128, None, 2^127))
N = p * q
e = 65537
d = inverse_mod(e, (p-1) * (q-1))
return e, d, N
def rsa_crypt(m, k, n):
return lift(Mod(m, n) ^ k)
(wer kein SageMath installiert hat, kann diese Online-Version verwenden)
Zu dem Code haben wir folgende Zahlen enthalten, die wir entschlüsseln sollen:
e = 65537
N = 6217468290706613301603616005528100131798149079252059573007495519788267044819121069395602245916998799930775648250683389442966714804739613450381148533419461928485596870928541962694971680190134542148996706927335882462229500089986851509475109505459048676123650490359710027902270147490153424656466031170743546488513755685818385587367321466024945007514143310365200975587058348169982977145650693955667384644381937407101389719894018805206516292068437770414857823797915459660636056085645464594641983523102970037856987032167809541108080330794363602778053
c = 2403958089131679913280388000590444805870336793994842072765238389702465658316012937918168208480675977995643226078413644628259138097045221263367315251880579357261568983261767728244111191268740525499918021532930079880132241418326592156659066605343402785460641431875333212535673641255300099993314785755934870975128143213605376650490207877022044023372044186152484758267679564160572404559310873497815049934655680334251964614447413366867181827019553147813581000873134348328652679149309707292737376026040119503186190733157879722025259970612661722013152
Hierbei ist c
ein Ciphertext und (e, N)
der öffentliche Schlüssel.
Lösung
Die Backdoor soll in rsa_keygen
sein. Schauen wir noch mal bei Wikipedia nach RSA.
Soweit sieht das alles identisch mit dem Standard-RSA-Vorgehen aus, bis auf diese Funktion a
und der offenbar willkürlich gewählten Konstante x
.
Um die gegebene Nachricht c
zu entschlüsseln, brauchen wir das Inverse d
(mod phi(N)
).
Dieses wird mit inverse_mod(e, (p-1) * (q-1))
berechnet. (p-1) * (q-1)
ist hierbei phi(N)
, die Eulersche Phi-Funktion von N
. phi(N)
lässt sich einfach berechnen, wenn man die Primzahlfaktorzerlegung (PFZ) von N
hat.
Unser N
ist nun das Produkt der Primzahlen p
und q
, die wir nicht kennen.
Wenn wir also p
und q
hätten, könnten wir daraus (p-1) * (q-1)
und somit d
berechnen.
Da die PFZ einer Zahl eindeutig ist, müssten wir “nur” N
faktorisieren, um zur Lösung zu kommen. Aus wie vielen Bits besteht denn N
?
>>> import math
>>> math.log2(N)
1806.4432827685064
Uff, 1800 Bit! Bei der Faktorisierungs-Rekordliste auf Wikipedia liegt der Rekord aktuell bei 829 Bit und benötigte 2700 CPU-Jahre.
Damit kommen wir hier nicht weiter.
p
und q
fallen aus der Funktion a(x, y)
heraus. Schauen wir uns mal die Funktion a
an:
def a(x, y):
z = 1
while True:
if is_prime(x*z + y):
return x*z + y
z += 1
y
ist hierbei eine 127- bis 128-Bit-Primzahl.
Die Funktion addiert so lange x
hinzu, bis das Ergebnis wieder prim ist.
Es wird also zumindest eine “echte” Primzahl zurückgegeben.
Die zurückgegebene Primzahl hat aber eine interessante Eigenschaft:
Nimmt man sie modulo x
, erhalten wir das ursprüngliche y
, also (x*z + y) % x == y
(wenn z eine ganze Zahl ist).
Wir schreiben für die ursprünglichen Primzahlen:
py = p (mod x)
qy = q (mod x)
py
und qy
sind die Zahlen, die der entsprechende random_prime
-Aufruf zurückgegeben hat.
Wenn wir diese beiden Zahlen hätten, könnten wir daraus p
und q
, damit phi(N)
und den privaten Schlüsselexponenten d
berechnen.
Wir wären also damit am Ziel.
Wir haben jetzt:
N = p * q
Mit dem Wissen über p
und q
bzw. py
und qy
und modularer Arithmetik können wir N % x
wie folgt schreiben:
N (mod x) = (p * q) (mod x)
# =>
N (mod x) = (py (mod x)) * (qy (mod x)) (mod x)
# =>
N (mod x) = py * qy (mod x)
Wir kennen py
und qy
zwar nicht, aber dafür können wir leicht N % x
berechnen. Machen wir das einfach mal:
>>> N % x
66125408326246957633291817320886184668904992358732144386954697003579825863329
# == py * qy (mod x)
Wir haben jetzt also py * qy
. Vielleicht können wir das faktorisieren?
>>> math.log2(N % x)
255.19173993737596
255 Bit, das sieht vielversprechend aus. Das ist sogar so klein, dass wir diese Zahl mit SageMath oder Pari/GP faktorisieren können. Letzteres läuft auch im Browser mit WebAssembly:
? factor(N % x)
%1 = [
221907985412741132065008213841199453713, 1;
297985708820960177865320211443397151633, 1
]
Das hat ein paar Minuten gedauert, aber es funktioniert! Wir haben jetzt also py
/qy
und können die einfach in den beretgestellten SageMath-Code einsetzen:
def rsa_keygen():
def a(x, y):
z = 1
while True:
if is_prime(x*z + y):
return x*z + y
z += 1
p = a(x, 221907985412741132065008213841199453713) # py eingesetzt
q = a(x, 297985708820960177865320211443397151633) # qy eingesetzt
N = p * q
e = 65537
d = inverse_mod(e, (p-1) * (q-1))
return e, d, N
e, d, N = rsa_keygen()
# d ist
# 3467768107026547385652638598807828312519916128200871775823473514729397520320938286000540898957902515126869436661843385176446317748106368775680640284756421439369120076034163821389280861589483618706566925985548751264810334876318558725999720428964472073154825463083731032087297027362897230895721168475580090760348500981461541577170237929409970063856773739334065914786957420755971894585049822901273421798773066522116201358512033300269512677528703251374687234206800845284169662237729684442740976846102023058025865707418543160234080009299622311844865
Jetzt können wir die ursprüngliche Nachricht entschlüsseln:
>>> decode(rsa_crypt(c, d, N))
b'HL{rsa_b4ckd00rz_mad3_s1mpl3}'
Fertig!
Takeaway und Weiterführendes
Nach Außen hin sah der generierte Schlüssel sicher aus. Kennt der Angreifer aber die (ggf. sogar geheime) Konstante x
, kann er das Kryptosystem mit einem Desktoprechner brechen. Das x
dient damit als Generalschlüssel für alle mit dem Verfahren erzeugten Schlüsselpaare.
In vielen Verfahren werden “Nothing-up-my-sleeve”-Zahlen verwendet. So kann man zeigen, dass man nicht mit einer geschickt gewählten Konstante versucht, eine Backdoor einzubauen (wie z. B. mit P und Q beim CSPRNG Dual_EC_DRBG).