Spooky Safebox
9 minutes to read
We are given source code of the server in Python. This is app.py
:
#!/usr/bin/env python3
import secrets
import os, sys, hmac
import cryptod
from proofofwork import challenge_proof_of_work
FLAG = os.environ.get("FLAG", "flag{FAKE_FLAG}") if "flag" in os.environ.get("FLAG","") else "flag{FAKE_FLAG}"
def main():
print("Welcome to the Spooky Safebox!")
if not challenge_proof_of_work():
return
kpriv, kpub = cryptod.make_keys()
order = cryptod.get_order()
encrypted_flag = cryptod.encrypt(kpub, FLAG)
print("Here is the encrypted flag:", encrypted_flag)
print("You've got 9 signatures, try to recover Satoshi's private key!")
for i in range(9):
msg_ = input("Enter a message to sign: >")
msg = hmac.new(cryptod.int_to_bytes(kpub.point.x() * i), msg_.encode(), "sha224").hexdigest()
checksum = 2**224 + (int(hmac.new(cryptod.int_to_bytes(kpriv.secret_multiplier) , msg_.encode(), "sha224").hexdigest(), 16) % (order-2**224))
nonce = secrets.randbelow(2 ** 224 - 1) + 1 + checksum
sig = kpriv.sign(int(msg, 16) % order, nonce)
print("Signature",(cryptod.int_to_bytes(int(sig.r)) + bytes.fromhex("deadbeef") + cryptod.int_to_bytes(int(sig.s))).hex())
print("Goodbye!")
if __name__ == '__main__':
try:
main()
except EOFError:
pass
except KeyboardInterrupt:
pass
And this is cryptod.py
:
import ecdsa, ecdsa.ecdsa
from cryptography.hazmat.primitives.kdf.kbkdf import (
CounterLocation, KBKDFHMAC, Mode
)
from cryptography.hazmat.primitives import hashes
import secrets
from Crypto.Cipher import ChaCha20_Poly1305
def get_order(): return ecdsa.NIST256p.generator.order()
def encrypt_sym(input_bytes: bytes, key:bytes):
cipher = ChaCha20_Poly1305.new(key=key)
ciphertext, tag = cipher.encrypt_and_digest(input_bytes)
return ciphertext + tag + cipher.nonce
def derive_symkey(inp:bytes):
kdf = KBKDFHMAC(
algorithm=hashes.SHA3_256(),
mode=Mode.CounterMode,
length=32,
rlen=4,
llen=4,
location=CounterLocation.BeforeFixed,
label=b"safu",
context=b"funds are safu",
fixed=None,
)
return kdf.derive(inp)
def make_keys():
gen = ecdsa.NIST256p.generator
secret = secrets.randbelow(gen.order()-1) + 1
pub_key = ecdsa.ecdsa.Public_key(gen, gen * secret)
priv_key = ecdsa.ecdsa.Private_key(pub_key, secret)
return priv_key, pub_key
def int_to_bytes(n: int) -> bytes:
return n.to_bytes((n.bit_length() + 7) // 8, 'big') or b'\0'
def encrypt(kpub_dest:ecdsa.ecdsa.Public_key, msg:str):
gen = ecdsa.NIST256p.generator
r = secrets.randbelow(gen.order()-1) + 1
R = gen * r
S = kpub_dest.point * r
key = derive_symkey(int_to_bytes(int(S.x())))
cp = encrypt_sym(msg.encode(), key).hex()
return cp + "deadbeef" + R.to_bytes().hex()
Source code analysis
The code is difficult to read, but after a bit of time, we can figure out what it does. The server uses an ECC and ECDSA on the curve P256.
The flag is encrypted with ChaCha20 and the key is derived from a curve point
All of these facts are present in cryptod.py
. The encrypted data is the ChaCha20 ciphertext and the point
In main.py
, after passing the proof of work, we are given the encrypted data and we are allowed to enter 9 messages that will be signed with ECDSA in a weird way:
for i in range(9):
msg_ = input("Enter a message to sign: >")
msg = hmac.new(cryptod.int_to_bytes(kpub.point.x() * i), msg_.encode(), "sha224").hexdigest()
checksum = 2**224 + (int(hmac.new(cryptod.int_to_bytes(kpriv.secret_multiplier) , msg_.encode(), "sha224").hexdigest(), 16) % (order-2**224))
nonce = secrets.randbelow(2 ** 224 - 1) + 1 + checksum
sig = kpriv.sign(int(msg, 16) % order, nonce)
print("Signature",(cryptod.int_to_bytes(int(sig.r)) + bytes.fromhex("deadbeef") + cryptod.int_to_bytes(int(sig.s))).hex())
Our input message is hashed with HMAC using
After that, this hash is used to generate a checksum
value using another SHA224-HMAC and
Then, the nonce checksum
, which is a 225-bit integer. Therefore, the nonces are 225-bit integers.
And finally, the server signs with ECDSA and outputs
ECDSA implementation
The code for the ECDSA is almost correct (more information here). We have this equation for
Where
The security flaw
The problem here is that the server generates nonces
As a result, we can use a lattice-based attack and LLL to solve the Hidden Number Problem. I found this technique in this website, which points to section 4 of this paper.
Hidden Number Problem
The HNP is usually defined as:
Where
We can define a lattice that contains the solution for the HNP. In the definition, we can use the lattice spanned by the columns of:
Where
Observe that
If we apply this procedure on the ECDSA equations we have, we will try to find the vector
Finding the public key
In order to apply the HNP to this biased nonces situation, we need to know the digests
It is possible to extract the public key
Where
Implementation
Let’s start by defining some helper functions:
def sign(m: bytes) -> Tuple[int, int]:
io.sendlineafter(b'Enter a message to sign: >', m)
io.recvuntil(b'Signature ')
r, s = map(lambda x: int(x, 16), io.recvline().decode().split('deadbeef'))
return r, s
def decrypt_sym(ct: bytes, key: bytes, nonce: bytes, tag: bytes) -> bytes:
return ChaCha20_Poly1305.new(key=key, nonce=nonce).decrypt_and_verify(ct, tag)
Now, let’s pass the proof of work (the challenge source code already includes a proofofwork.py
to solve this):
io = get_process()
io.recvuntil(b'Please provide a string that starts with ')
challenge = io.recvuntil(b' and whose sha256 hash starts with', drop=True).decode()
prefix = io.recvline().decode().strip()
result = solve_pow(challenge, prefix)
io.sendlineafter(b'POW: >', result.encode())
Then, let’s sign the first message and try to get the public key
io.recvuntil(b'Here is the encrypted flag: ')
enc_data = bytes.fromhex(io.recvline().decode())
m0 = b'asdf0'
r0, s0 = sign(m0)
h0 = int(hmac.new(b'\0', m0, 'sha224').hexdigest(), 16)
m1 = b'asdf1'
r1, s1 = sign(m1)
for P in E.lift_x(Fp(r0), all=True):
Q = pow(r0, -1, n) * (s0 * P - h0 * G)
h1 = int(hmac.new(long_to_bytes(int(Q[0])), m1, 'sha224').hexdigest(), 16)
if (pow(s1, -1, n) * (h1 * G + r1 * Q))[0] == r1:
break
else:
io.failure('Failed to find public ECDSA key')
exit(1)
io.info(f'Public ECDSA key: {Q}')
h, r, s = [h0, h1], [r0, r1], [s0, s1]
Now that we have the public key
h, r, s = [h0, h1], [r0, r1], [s0, s1]
for i in range(2, 9):
mi = f'asdf{i}'.encode()
ri, si = sign(mi)
hi = hmac.new(long_to_bytes(int(Q[0]) * i), mi, 'sha224').hexdigest()
h.append(int(hi, 16))
r.append(ri)
s.append(si)
At this point, we can model and solve the HNP using LLL as follows (
p = n
a = list(map(lambda s_i, h_i: pow(s_i, -1, p) * h_i % p, s, h))
t = list(map(lambda r_i, s_i: pow(s_i, -1, p) * r_i % p, r, s))
X = 2 ** 225
raw_matrix = []
for i in range(len(a)):
raw_matrix.append([0] * i + [p] + [0] * (len(a) - i + 1))
raw_matrix.append(t + [X / p, 0])
raw_matrix.append(a + [0, X])
M = Matrix(QQ, raw_matrix)
L = M.LLL()
Once we have the reduced lattice basis, we need to check which vector is the target one. We can do this by checking if the upper bound
enc_flag, R_bytes = enc_data.split(b'\xde\xad\xbe\xef')
R_mpz = ellipticcurve.Point.from_bytes(NIST256p.curve, R_bytes)
R = E(R_mpz.x(), R_mpz.y())
ct, tag, nonce = enc_flag[:-28], enc_flag[-28:-12], enc_flag[-12:]
for row in L.rows():
if row[-1] == X:
k = list(map(int, row[:-2]))
x = (s[0] * k[0] - h[0]) * pow(r[0], -1, p) % p
io.info(f'Private key: {x}')
S = x * R
key = derive_symkey(long_to_bytes(int(S[0])))
try:
io.success(f'Flag: {decrypt_sym(ct, key, nonce, tag).decode()}')
exit(0)
except ValueError:
io.failure('MAC check failed')
continue
Flag
If we run the script, we will get the flag:
$ python3 solve_safebox.py flu.xxx 10030
[+] Opening connection to flu.xxx on port 10030: Done
[*] Public ECDSA key: (2632898350580737258169039806927386431504104360833563771125317252526522550904 : 77026766777862121462475060111493786243486512461424268224672289571158997563209 : 1)
[*] Private key: 103023634815621571102628370470165979788796885143680181972239572475026982389573
[+] Flag: flag{s4tosh1s_Funds_4re_safu_safeB0x_isnt}
[*] Closed connection to flu.xxx port 10030
The full script can be found in here: solve.py
.