TSJ CTF 2022 (II): Signature
Breaking 256-bit ECDSA with $k = z \oplus d$ with only six signatures
Signature is a crypto challenge from TSJ CTF 2022, which ended up having two solves. From this challenge, we can see how ECDSA private keys can be recovered by having a (weak) deterministic ephemeral key, $k$.
Challenge Summary⌗
Suppose that $d$ is the private key for ECDSA (over curve secp256k1). We are given six signatures signed with the above private key. However, $k$ is computed by $k = d \oplus z$ ($z$ is the hash of the message) rather than generated randomly.
$d$ is then used to derive a key for AES-CTR which is used to encrypt the message, which is in the form Congrats! This is your flag: [FLAG]
. The objective is to recover the flag.
Solution⌗
Part I: Modelling the unknowns with a linear congruence⌗
Below is the most important equation that is used to solve the challenge:
\[k \cdot s \equiv z + r \cdot d\ (\text{mod}\ n) \qquad [\dagger]\]
Where does the equation comes from? When signing a digest $z$, we have
$$s = k^{-1} (z + r \cdot d)\ (\text{mod}\ n).$$
Therefore, we can obtain $[\dagger]$ by rearranging the terms.
From the challenge setting, we have six pairs of $(z, r, s)$. Also, $k$ is deterministic and is given by $k = d \oplus z$. If we write
\[k = \sum_{i=0}^{255} 2^i \cdot k_i \qquad \text{and} \qquad d = \sum_{i=0}^{255} 2^i \cdot d_i,\]
here $k_i, d_i \in \{0, 1\}$. We can express $k_i$ in terms of $d_i$ for each $i = 0, 1, ..., 255$. If we denote $z_i$ by the $i$-th bit of $z$, we have the below truth table that everybody knows:
$d_i$ | 0 | 0 | 1 | 1 | $z_i$ | 0 | 1 | 0 | 1 | $k_i = d_i \oplus z_i$ | 0 | 1 | 1 | 0 |
---|
Since $z_i$'s are known, we can simply write $k_i$ in terms of $d_i$:
\[k_i = \begin{cases} d_i & \text{if}\ z_i = 0 \\ 1 - d_i & \text{otherwise} \end{cases}.\]
It is just that simple. If we let $i = i_1, i_2, ..., i_p$ to be the indexes such that $z_i = 0$ and $i = j_1, j_2, ..., j_q$ be the indexes such that $z_i = 1$, we can rewrite $[\dagger]$ as an equation with 256 bits of unknown (reduced from 512 bits):
\[\begin{aligned} &0 \equiv z + r \cdot \left(\sum_{i=0}^{255} 2^i \cdot d_i\right) - \left(\sum_{i=0}^{255} 2^i \cdot k_i\right) \cdot s \\ & \qquad \equiv z + r \cdot \left(\sum_{u=1}^p 2^{i_u} \cdot d_{i_u} + \sum_{u=1}^q 2^{j_u} \cdot d_{j_u}\right) - \left(\sum_{u=1}^p 2^{i_u} \cdot k_{i_u} + \sum_{u=1}^q 2^{j_u} \cdot k_{j_u}\right) \cdot s \\ & \qquad \equiv z + r \cdot \left(\sum_{u=1}^p 2^{i_u} \cdot d_{i_u} + \sum_{u=1}^q 2^{j_u} \cdot d_{j_u}\right) - \left(\sum_{u=1}^p 2^{i_u} \cdot d_{i_u} + \sum_{u=1}^q 2^{j_u} \cdot (1 - d_{j_u})\right) \cdot s \\ & \qquad \equiv z + r \cdot \left(\sum_{u=1}^p 2^{i_u} \cdot d_{i_u} + \sum_{u=1}^q 2^{j_u} \cdot d_{j_u}\right) - \left(\sum_{u=1}^p 2^{i_u} \cdot d_{i_u} + \sum_{u=1}^q 2^{j_u} - \sum_{u=1}^q 2^{j_u} \cdot d_{j_u}\right) \cdot s \\ & \qquad \equiv \sum_{u=1}^p 2^{i_u} (r - s) \cdot \htmlStyle{color: yellow;}{d_{i_u}} + \sum_{u=1}^q 2^{j_u} (r + s) \cdot \htmlStyle{color: yellow;}{d_{j_u}} + z - \sum_{u=1}^q 2^{j_u} \quad (\text{mod}\ n) \qquad\qquad [\dagger'] \end{aligned}\]
Part II: LLL FTW (as always)⌗
Since $[\dagger']$ is affine, we can write it as
\[a_0 \cdot \htmlStyle{color: yellow;}{d_0} + a_1 \cdot \htmlStyle{color: yellow;}{d_1} + ... + a_{255} \cdot \htmlStyle{color: yellow;}{d_{255}} + b \equiv 0 \quad (\text{mod}\ n), \qquad\qquad [\dagger'']\]
where $a_0, a_1, ..., a_{255}, b$ are all known constants.
Although the size of unknown is reduced from 512 bits to 256 bits, it is not enough for us to recover those $d_i$'s because we only have 256 bits of information.
In reality, we have six pairs of $(z, r, s)$. This is equivalent to $256 \times 6 = 1536$ bits of information. At the same time, they shares the same unknowns $d_0, d_1, ..., d_{255}$, which is only 256 bits. Thus for $i = 0, 1, ..., 5$, we have the below equations (based on $[\dagger'']$):
\[a_{i,0} \cdot \htmlStyle{color: yellow;}{d_0} + a_{i,1} \cdot \htmlStyle{color: yellow;}{d_1} + ... + a_{i,255} \cdot \htmlStyle{color: yellow;}{d_{255}} + b_i \equiv 0 \quad (\text{mod}\ n)\]
Since we have much more information than the unknown and the relation is affine, we can use LLL to find those $d_i$'s. Anyway this is the lattice we used to LLL:
\[A = \left[\begin{array}{ccc|ccc|c} a_{0,0} & & a_{5,0} & 1 & & & \\ & \ddots & & & \ddots & & 0 \\ a_{0,255} & & a_{5,255} & & & 1 & \\ \hline b & \dots & b & & 0 & & 1 \\ \hline n & & & & & & \\ & \ddots & & & 0 & & 0 \\ & & n & & & & \\ \end{array}\right]\]
It would span to the vector $\left[\begin{array}{ccc|ccc|c}0 & \dots & 0 & d_0 & \cdots & d_{255} & 1 \end{array}\right]$ because
\[ \left[\begin{matrix} 0 \\ \vdots \\ 0 \\ \hline d_0 \\ \vdots \\ d_{255} \\ \hline 1 \end{matrix}\right] = A^T \left[\begin{matrix} d_0 \\ \vdots \\ d_{255} \\ \hline 1 \\ \hline \psi_0 \\ \vdots \\ \psi_5 \end{matrix}\right]. \]
Here each $\psi_i$ is just a number that allow the expression to take "$\text{mod}\ n$", and we don't care about what that is. We can write $[\dagger'']$ (again!) to show what $\psi_i$ is:
\[a_0 \cdot \htmlStyle{color: yellow;}{d_0} + a_1 \cdot \htmlStyle{color: yellow;}{d_1} + ... + a_{255} \cdot \htmlStyle{color: yellow;}{d_{255}} + b + n \cdot \htmlStyle{color: yellow;}{\psi_i} = 0\]
We have d = 0xfadda15bda82cc9f8779bff57fb23e3a6b5d0e50dfd809249d36c1af0ed258f4
from LLL. Now we have the AES key. We are almost done!
Part III: Recovering the flag from AES-CTR⌗
Now we have d
. The last few lines of their challenge.py
lies the last problem yet to be solved. It must be straight forward!
msg = f"Congrats! This is your flag: {flag}"
key = sha256(str(d).encode()).digest()[:16]
cipher = AES.new(key, AES.MODE_CTR)
print(cipher.encrypt(msg.encode()))
During the contest, I was very puzzled since I was unable to recover the flag with the below snippet:
cipher = AES.new(key, AES.MODE_CTR)
print(cipher.decrypt(c))
Of course I don't! From the documentation of PyCryptodome, a 8-byte nonce will be generated whenever the nonce
parameter is not present.
I thought that exhausting a 8-byte nonce is infeasible. Sarcastically, this is not the first time I have such thoughts. I even DMed @maple3142 to ask if the setting is correct... Of course that is!
Well, as we have the first 16 bytes of the message (Congrats! This i
), we can get the first 16 bytes of the keystream. We have nonce || counter
by simply decrypting the keystream. We have the key to decrypt stuffs, after all...
After fixing the stupid mistakes those I made, we have the flag:
TSJ{who_needs_gaussian_elimination_when_you_have_LLL?_https://github.com/jonasnick/ecdsaPredictableNonce}
Solution script⌗
import hashlib
from Crypto.Cipher import AES
from fastecdsa.curve import secp256k1 as CURVE
def xor(a, b):
return bytes([u^^v for u, v in zip(a, b)])
q = CURVE.q
sigs = [
[68628903551760154000300039815420920736833607628328728397865761689137575263983, 15443603266495080692405049373908575802334630966756172054216234269624270469394, 22981409647080659483954459639297160899607455746832364780169145673684744217331],
[78718753887900409677937247756515988306958319672631190519685073607080826418294, 54276644149626679124071775426283215897696024493497440097979013953212408253734, 55041549251920287655053310452444903772819013365729781587201778245299568302064],
[76095801129518609512809073275431964691465208338605553566026645469843430112342, 13243268453692556572476611015151647125720859921369299900977957389460914346512, 36340755739784353369950946724334315371788689739091478499529319569139128625422],
[14730727965324371353827007106163274690512416783313793290093026161256262981745, 61155679185854480315452772506628842842531730179984684102346013415628266033721, 23105235892108839369406685343889200404631332972108556923344303689260664287078],
[1873902112306026140517260545900147482389676444908353863350522632481552764819, 7259111370509779279520618531388517380717524647694957730980169106713190467749, 47964977855256430218836508671575141052659514288194710563910728384541064927004],
[82140969634605912978034198684963146226227701184998942661897478141366806021323, 83925169940944825117018170424384235795850122118608830570830266120233738605795, 18779900738289102423955738918979596192495903397022523360371032408971788920887],
]
c0 = b'\xd8n\x81\xcfrN=\xa8\xda\xa0pIR\xdb8\x98\xdb.s;\xecOG\xe2\x07\x99\x93\x1ah\x08G\xb2\x82\xe5\xef2\xe9\x92\x8b\xfaA\x8f\xa3\xa5\xcc8\x90\x95\xff?\xef\x1c\xfd\xffL(\xb46H\x0e/z/\xfe\x9eh[\xb3\xc6Y\x0e\x90\xe5\xdaU\xcc\x84\xed\x81\xcb\x1d<`]\xd2E\xd9\xa3\xa5u\xa3/b\xf3qz\x19\xfa\x8e$\xa3S\xac{,\xbcYIZ\xa9Z\xd6M-\x06Io>\xb2\xa35\x97=\x10]\xb36\xf6\xb2.\x8c\xd48\xe4'
# Note: d is the secret key
# d = d0 + 2 * d1 + ... + 2^255 * d255
# we can know each k in terms of d because if
# zi = 0 then ki = di, and zi = 1 then ki = 1-di
# s * (k0 + 2 k1 + ... + 2^255 k255) = z + r * (d0 + 2 d1 + ... + 2^255 d255) (mod q)
P = PolynomialRing(Zmod(q), [f'd{k}' for k in range(256)])
ds = P.gens()
A = Matrix(ZZ, 6+1+256)
print('[!] Defining the matrix')
for j, (z, r, s) in enumerate(sigs):
zs = [(z>>i)&1 for i in range(256)]
ks = []
for zi, di in zip(zs, ds):
if zi == 0: ks.append(di)
else: ks.append(1 - di)
# f = s * k - (z + r * d)
f = s * sum([2**i * ki for i, ki in enumerate(ks)])
f -= z + r * sum([2**i * di for i, di in enumerate(ds)])
for i in [0]:
A[i, j] = f.constant_coefficient()
for i in range(256):
A[i+1, j] = f.coefficient({ ds[i]: 1 })
for i in [j+257]:
A[i, j] = q
for i in range(257):
A[i, i+6] = 1
print('[*] Defining the matrix: Done')
print('[!] Running LLL')
A = A.LLL()
print('[*] Running LLL: Done')
for row in A:
if row[6] < 0: row = -row
if row[6] != 1: continue
if min(row[:6]) < 0: continue
if max(row[:6]) > 0: continue
if min(row[7:]) < 0: continue
if max(row[7:]) > 1: continue
ds = row[7:]
d = int(sum(2**i * di for i, di in enumerate(ds)))
key = hashlib.sha256(str(d).encode()).digest()[:16]
cipher = AES.new(key, AES.MODE_ECB)
m1 = cipher.decrypt(xor(b'Congrats! This is your flag: TSJ', c0[:32]))
cipher = AES.new(key, AES.MODE_CTR, nonce=m1[:8])
m0 = cipher.decrypt(c0)
print(f'[*] {d = }')
print(f'[*] {key = }')
print(f'[*] {m1 = }')
print(f'[*] {m0 = }')
'''
[!] Defining the matrix
[*] Defining the matrix: Done
[!] Running LLL
[*] Running LLL: Done
[*] d = 113469799004661487320247409448796883146298153010773207076143627146899378821364
[*] key = b"\xc4\xb1\x8d\xdc\xa2#\xa0\xc6\xe5\xa3'\x89,\xd0S\xd2"
[*] m1 = b'\xb7\x8f\x8a\xd1\x07\x1b\x1e?\x00\x00\x00\x00\x00\x00\x00\x00\xb7\x8f\x8a\xd1\x07\x1b\x1e?\x00\x00\x00\x00\x00\x00\x00\x01'
[*] m0 = b'Congrats! This is your flag: TSJ{who_needs_gaussian_elimination_when_you_have_LLL?_https://github.com/jonasnick/ecdsaPredictableNonce}'
'''