$ cat writeup.md…
$ cat writeup.md…
HackTheBox
Task: ECC oracle with missing point validation + quantum key exchange using Bell states and Python PRNG. Solution: invalid-curve attack via singular curve y^2=x^3 recovers private key in one query, then PRNG prediction + Bell-state anti-correlation recovers AES key.
"In our company, we use Elliptic Curve Cryptography to encrypt our internal communications. Fearing the consequences that the rise of quantum computing will bring, we decided to implement a new key exchange scheme. While researching Quantum Cryptography, we came across a strange concept called entanglement and decided to incorporate it into our server."
We are given a server with two menu options:
The exploit is a true two-stage chain:
The relevant code is in twisted_entanglement/server.py and twisted_entanglement/util.py.
server.py exposes this behavior:
point = parseUserPoint(user_point) public_key = multiply(private_key, point, E)
The curve parameters are secp256k1-style:
p = 115792089237316195423570985008687907853269984665640564039457584007908834671663 a = 0 b = 7
However, util.py implements point addition and doubling using only a and p:
def add(P, Q, a, m): ... if (P[0] == Q[0] and P[1] == Q[1]): S = ((3 * (pow(P[0], 2)) + a) * eea(2 * P[1], m)[1]) % m else: S = ((Q[1] - P[1]) * eea((Q[0] - P[0]) % m, m)[1]) % m
The implementation never checks whether a user-supplied point lies on the intended curve
y^2 = x^3 + 7 (mod p), and it never uses b = 7 in the group law.
So the bug is not a weakness in secp256k1 itself. The actual issue is:
b,That is exactly an invalid-curve vulnerability.
generateKeys() reseeds Python's PRNG every time with the same private_key:
def generateKeys(basis, private_key): seed(private_key)
For each of 256 rounds it:
randint(0, 1),The server key is hashed with SHA-256 and used as the AES-ECB key:
q_server_key = bitsToHash(q_server_key) cipher = AES.new(key, AES.MODE_ECB)
For short Weierstrass curves, the point-addition formulas depend on a, but not explicitly on b. That is normal only if all points are guaranteed to lie on the same valid curve.
Here the server accepts arbitrary coordinates and still runs the formulas. Because there is no membership check, we can submit points from a different curve that shares the same a = 0.
This is the invalid-curve attack surface.
The strongest choice is the singular cubic:
y^2 = x^3
Take a nonzero point (x, y) on this curve. Since y^2 = x^3, we can write points in a rational parameter t with the map:
\phi(P) = -x / y \pmod p
For this singular curve, the chord-and-tangent law induced by the server formulas corresponds to ordinary multiplication of that parameter:
\phi(P + Q) = \phi(P) + \phi(Q)
So scalar multiplication becomes:
\phi(dP) = d \cdot \phi(P)
If we choose
P = (1, -1 \bmod p)
then P lies on y^2 = x^3 and
\phi(P) = -1 / (-1) = 1
Therefore, for the server response Q = dP:
\phi(Q) = d
and we recover the secret scalar directly as:
d = -Q_x / Q_y \pmod p
This is why one oracle query is enough.
The local exploit uses:
ATTACK_POINT = [1, P - 1] def phi(point): x, y = point return (-x * pow(y, -1, P)) % P
The returned point from option 1 is Q = private_key * ATTACK_POINT, so phi(Q) gives the private key immediately.
The live exploit recovered:
3262827136301000405966
which also satisfies the server-side bound private_key < 8748541127929402731638.
The qubit preparation sequence is:
ns.qubits.operate(q1, ns.X) ns.qubits.operate(q1, ns.H) ns.qubits.operate(q2, ns.X) ns.qubits.combine_qubits([q1, q2]) ns.qubits.operate([q1, q2], ns.CX)
This prepares the Bell state:
|\Psi^-\rangle = (|01\rangle - |10\rangle)/\sqrt{2}
This state has perfect anti-correlation when both qubits are measured in the same basis:
Z basis,X basis as well.So the quantum stage is not a probabilistic brute force. Once the private key is known, the attack is deterministic:
random with the recovered private key,Because the server reseeds with the same private key on every query, the basis sequence is fully predictable.
Send the singular-curve point:
(1, -1 mod p)
The server returns Q = dP. Compute:
d = -Q_x / Q_y \pmod p
Recovered live key:
3262827136301000405966
solve.py reproduces the sequence with:
def predict_basis(private_key): rnd = random.Random(private_key) return "".join("Z" if rnd.randint(0, 1) else "X" for _ in range(256))
Submit the exact predicted basis string to option 2.
The server returns a hex string representing the user measurement bits. Convert that hex to 256 bits and invert each bit, since same-basis measurements on |Psi^-> are anti-correlated.
Hash the recovered 256 server bits with SHA-256, then decrypt the ciphertext with AES-ECB.
solve.py SummaryThe local exploit script is solve.py.
It works as follows:
[1, p-1],phi(Q) to recover the private key,random.Random(private_key),The same script also contains a MockOracle self-test path, which was used locally to validate the full exploit chain without the real secret file.
The exploit succeeded locally against the mock oracle and remotely against the live target. The final flag was:
HTB{REDACTED}
HTB{REDACTED}
$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar