Here's a somewhat late writeup for the three-stage crypto composition gipfel/kipferl/zipfel from our recent CTF.
The overall goal in all three challenges was to break a password-authenticated key exchange (PAKE) protocol inspired by SPEKE. The core idea is simple: Do Diffie–Hellman as usual, but with a generator that's derived from a pre-shared key ("the password"), and add a key-confirmation stage.
In all three stages, the key step of the solution was to reduce the problem to offline bruteforcing of the (short, numeric) password.
Since it is the simplest, let's have a look at the source code for "gipfel" first:
#!/usr/bin/env python3
from Crypto.Hash import SHA256
from Crypto.Cipher import AES
import signal, random
random = random.SystemRandom()
q = 0x3a05ce0b044dade60c9a52fb6a3035fc9117b307ca21ae1b6577fef7acd651c1f1c9c06a644fd82955694af6cd4e88f540010f2e8fdf037c769135dbe29bf16a154b62e614bb441f318a82ccd1e493ffa565e5ffd5a708251a50d145f3159a5
def enc(a):
f = {str: str.encode, int: int.__str__}.get(type(a))
return enc(f(a)) if f else a
def H(*args):
data = b'\0'.join(map(enc, args))
return SHA256.new(data).digest()
def F(h, x):
return pow(h, x, q)
################################################################
password = random.randrange(10**6)
def go():
g = int(H(password).hex(), 16)
privA = 40*random.randrange(2**999)
pubA = F(g, privA)
print(f'{pubA = :#x}')
pubB = int(input(),0)
if not 1 < pubB < q:
exit('nope')
shared = F(pubB, privA)
verA = F(g, shared**3)
print(f'{verA = :#x}')
verB = int(input(),0)
if verB == F(g, shared**5):
key = H(password, shared)
flag = open('flag.txt').read().strip()
aes = AES.new(key, AES.MODE_CTR, nonce=b'')
print(f'flag:', aes.encrypt(flag.encode()).hex())
else:
print(f'nope! {shared:#x}')
# three shots, three opportunities
# to seize everything you ever wanted
# would you capture? or just let it slip?
signal.alarm(2021)
go()
go()
go()
In a nutshell, this code:
On success, the server encrypts the flag using a hash of
I won't spend too much time discussing "gipfel" as it was the most-solved challenge of the entire CTF.
crypto ez lol
— hxp CTF players, probably
Short summary:
Pick pubB
as any element of order 2 or 4;
this makes shared == 1
;
hence verA == H(password)
and we can bruteforce the correct password offline
by hashing all possible choices
and comparing to the received verA
value.
In the second iteration of go()
,
simply run the protocol honestly with the correct password.
Elements of order 2 or 4 are
The next incarnation
"kipferl"
of this challenge was
pretty much the same overall thing,
but using the elliptic curve
Here's the entire diff
:
--- gipfel/vuln.py
+++ kipferl/vuln.py
@@ -5,6 +5,29 @@
random = random.SystemRandom()
q = 0x3a05ce0b044dade60c9a52fb6a3035fc9117b307ca21ae1b6577fef7acd651c1f1c9c06a644fd82955694af6cd4e88f540010f2e8fdf037c769135dbe29bf16a154b62e614bb441f318a82ccd1e493ffa565e5ffd5a708251a50d145f3159a5
+a, b = 1, 0
+
+################################################################
+
+# https://www.hyperelliptic.org/EFD/g1p/data/shortw/xz/ladder/ladd-2002-it
+def xDBLADD(P,Q,PQ):
+ (X1,Z1), (X2,Z2), (X3,Z3) = PQ, P, Q
+ X4 = (X2**2-a*Z2**2)**2-8*b*X2*Z2**3
+ Z4 = 4*(X2*Z2*(X2**2+a*Z2**2)+b*Z2**4)
+ X5 = Z1*((X2*X3-a*Z2*Z3)**2-4*b*Z2*Z3*(X2*Z3+X3*Z2))
+ Z5 = X1*(X2*Z3-X3*Z2)**2
+ X4,Z4,X5,Z5 = (c%q for c in (X4,Z4,X5,Z5))
+ return (X4,Z4), (X5,Z5)
+
+def xMUL(P, k):
+ Q,R = (1,0), P
+ for i in reversed(range(k.bit_length()+1)):
+ if k >> i & 1: R,Q = Q,R
+ Q,R = xDBLADD(Q,R,P)
+ if k >> i & 1: R,Q = Q,R
+ return Q
+
+################################################################
def enc(a):
f = {str: str.encode, int: int.__str__}.get(type(a))
@@ -15,7 +38,8 @@
return SHA256.new(data).digest()
def F(h, x):
- return pow(h, x, q)
+ r = xMUL((h,1), x)
+ return r[0] * pow(r[1],-1,q) % q
################################################################
The change to an elliptic curve doesn't necessarily change
anything in principle, but it so happens that
this particular implementation of
the elliptic-curve operations
(or rather, the dehomogenization step in F
)
crashes with a
ZeroDivisionError
exception
whenever it encounters the point at infinity.
Hence, small-subgroup attacks like for "gipfel" aren't applicable anymore.
Instead,
we simply run the key exchange once (in any way)
to obtain a pair of verA
and shared
values from the server.
Recalling that verA
equals
Finally, the real raison d'être for all three of these challenges:
"zipfel".
It taketh away verA
and giveth back absolutely nothing:
--- kipferl/vuln.py
+++ zipfel/vuln.py
@@ -58,9 +58,6 @@
shared = F(pubB, privA)
- verA = F(g, shared**3)
- print(f'{verA = :#x}')
-
verB = int(input(),0)
if verB == F(g, shared**5):
key = H(password, shared)
As far as I know,
this challenge would be unsolvable if the
underlying group was "generic".
However, the elliptic curve
Now, unfortunately, it turns out I made a small mistake while picking the parameters. It didn't make the challenge (much) easier, but I think the unintended solution is significantly less pretty. I'll first explain what I originally had in mind and then show the simplified version.
The upshot is that
the pairing on the elliptic-curve group allows us to solve a variant of the DDH problem,
giving rise to an oracle which can be used to bruteforce passwords offline
like in the earlier challenges.
In short, we
may send a random
(Note that the "usual" relationship
Thus, for each candidate password,
we may
compute the corresponding generator point
Given, is it true that ?
It is clear that the correct guess
Long story short:
The Weil pairing
(specialized to our setting)
is an efficient* map
(Here,
The reason this is relevant here is because
pairings solve decisional problems:
Given
Interestingly,
the only case where this fails
is precisely the DDH situation
where
Indeed, this exact strategy works for the CTF challenge:
Send a random
At the heart of the simpler solution is that the hash function
mapping strings to curve points was not restricted to a cyclic subgroup
of the curve. This means that the points obtained by hashing candidate
passwords are almost certainly linearly independent, and therefore only
one of them — the real
Thus, after receiving
Lesson learned: Pairings also solve (cyclic) subgroup membership. :^)
The fact that the server uses
In reality, the Tate pairing will be noticeably faster (and can be applied in approximately the same way), but in order to keep things straightforward I've restricted the discussion here to the Weil pairing.
My own implementation of the (Tate) pairing solves the challenge within a single connection on a 4-core laptop. Other people have successfully used SageMath on bigger machines. (Also note that it wasn't really necessary to solve the challenge within a single connection, so being slower was fine too.)
Also, since some people complained about the allegedly excessive proof of work on these challenges: This was because the entire point was to break the PAKE in the sense of converting online bruteforce to offline bruteforce; hence, bruteforcing online clearly had to be costly.