tl;dr
- ECDSA signing server with biased nonce
- Exploitation by modelling a EHNP instance and using z3 Solver for breaking Mersenne Twister
Challenge Points: 818
No. of solves: 19
Challenge Author: AeroSol
Challenge Description
“You hunger to claim victory in a war that ended without you.”
Introduction
This is the challenge I made for bi0sCTF 2025. I wanted to make an EHNP based challenge for sometime now so I made it with some Ultrakill theme.
Given information
From the given files we know
- There is ECDSA happening using secp256k1 curve.
- The Private Key is generated using a custom prng function called
supreme_PRNG()
. - The Nonce is generated using
full_noncense_gen()
which internally makes calls to theRMT
viasec_real_bits()
andpartial_noncense_gen()
. - There is a small
perform_deadcoin()
mini-game happening where you solve a DLP problem. - There is a interface through which we can send JSON encoded “routines” to perform 5 different actions:
get_encrypted_flag
-> gives you the AES-CBC encrypted ciphertext and IV.perform_deadcoin
-> gives you the chance to heal your HP bar if you solve a DLP problem.call_the_signer
-> signs any arbitrary message and returnsr
ands
values along withnonce_gen_consts
andheat_gen
.level_restart
-> lets you restart the level and regenerates the Private Key and seed for the RMT.level_quit
-> well duh, let’s you quit the connection.
RMT
which seems to have a different seed initialisation function when compared to python’s MT.
Analysis
From quite literally glancing through the signing system, we can tell that the nonce generation and private key generation has some inherent bias and moreover the chunks are non-continuous, so we can model the problem as a EHNP
instance and perform LLL to solve it.Note: the success probability of solving the EHNP from our approach is given in a paper further in this post.
But there seems to be a catch
1 | elif v1_action == "call_the_signer": |
The HP will deteriorate by -20
for every message we sign thus limiting us to two signatures before we can query for the encrypted flag.
Now to approach the challenge, we need enough HP to sign at least 5 messages before we can find the private key using LLL.
PRNGS
Let’s analyse each of the PRNGS:
RMT
- The only difference between python’s MT and RMT is the
seedMT()
function or more popularly called as Ripley’s Seeding. This version of MT is used in the programming language R. - It uses a 32 bit seed and uses the same 32 bit parameters of a standard MT.
seedMT()
tries to grow a value and update the internal state of the MT.1
2
3
4
5
6
7
8
9
10
11
12def seedMT(self, seed):
num = seed
self.index = self.n
for _ in range(0,51):
num = 69069 * num + 1
g_prev = num
for i in range(self.n):
g = 69069 * g_prev + 1
self.MT[i] = g & self.d
g_prev = g
return self.MT- MT is reversible with z3 Solver and as for
seedMT()
it can also be easily modelled in z3.
- The only difference between python’s MT and RMT is the
supreme_RNG()
- It uses the Middle Square Method to generate the next number.
- MSM is not cryptographically secure as the generated values will decay and remain stagnant producing a very short cycle.
There is also a simple LCG which is used only once per restart as:
1 | def privkey_gen(self) -> int: |
1 | ... |
it is only called when self.cinit
is 0.
Other important funcs
perform_deadcoin()
as we have previously established, let’s you play the mini-game where you solve a DLP problem in-exchange for a 32 bit value from theRMT
but there is a hard limit of only being able to play this thrice.- Now, after analysing
deadcoin_verification()
and_dead_coin_params()
, the mini-game seems to derive it’s exponent fromsupreme_RNG()
which takes its seed from the simple LCG. - The simple LCG always produces the value
1569250000
as it is always seeded withCORE
and is run only once per level. - This value specifically decays to 0 at the 375 cycle.
Now let’s look at full_noncense_gen()
- It introduces
k_m1, k_m2, k_m3, k_m4
which are cryptographically secure but add bias andk_ and _k
which are generated fromRMT
along withbenjamin1, benjamin2
k_ and _k
are both 32 bit values andbenjamin1 and benjamin2
are 16 bit values.benjamin1
is split into chunks of one byte and is made to be every alternate chunk in the nonce except for the last alternate chunk where 12 lsb ofbenjamin2
is used.- Both the benjamin values are generated by
partial_noncense_gen()
and are of the form: $$y \gets b \ \oplus (b \ll s) \ \wedge \ a$$ where $b \in [0,2^{32}]$ and $y,a \in [0,2^{16}]$ - The
full_noncense_gen()
also returnsn1
andn2
values which are later given asnonce_gen_consts
which areequation, _and
value frompartial_noncense_gen
. - It also returns
cycles
which are later given out asheat_gen
which denotes the number of calls toRMT
before getting the right bit length.
In decor.py
we can also see that there are no checks on going beyond 100 HP, so we can try to over-heal and get more signatures.
From all the above information, we can formulate our exploit.
Formulating exploit
Restart the level 375 times.
Now the exponent in
deadcoin_verification()
will be 0 so you can easily pass this check three times and get 3RMT
values and reconstruct the seed using z3 Solver. This also sets your HP to 160.Now we sign any arbitrary message five times (our maximum limit) whilst simultaneously accounting for the number of
RMT
calls from theheat_gen
. We can also rebuild both benjamin values using z3 Solver.As there is some bias introduced in the private key, we assign it to the var
xbar
and also calculatekbar
with the appropriate shifts.We can now construct $\pi_{i}$ and $\nu_{i}$ from the below equation: $$d \ = \ \bar{d} \ +\ \sum_{j=1}^{m} 2^{\pi_{j}}d_{j} \ \ , \ \ 0 \leq d_{j} \leq \ 2^{\nu_{j}}$$ using the appropriate shifts and bit lengths
Similarly we can construct $\lambda_{i,j}$ and $\mu_{i,j}$ from the below equation: $$k_{i} = \bar{k_{i}} +\sum_{j=1}^{l_{i}}2^{\lambda_{i,j}}k_{i,j} \ \ , \ \ 0 \leq k_{i,j} < 2^{\mu_{i,j}}$$ using the appropriate shifts and bit lengths given in the code.
We can now convert this into a CVP instance and use LLL to solve it to find the private key.
The basis: $$B = \begin{bmatrix}n \cdot I_{x}\\ A&X\\ R&&K \end{bmatrix}$$Each of the internal matrices can be referred from this amazing paper by Joseph Surin (EHNP part).
- In essence let $$x = (r_{1},\dots, r_{x},d_{1},\dots,d_{m},d_{1,1}\dots,d_{x,l_{1}},\dots,d_{x,l_{x}})$$ We would have, $$xB = u$$
$$u =( \beta_{1} - \alpha_{1}\bar{d},\dots,\beta_{x} - \alpha_{x}\bar{d}, \frac{d_{1}\delta}{2^{\nu_{1}}},\dots,\frac{d_{m}\delta}{2^{\nu_{m}}}, \frac{k_{1,1}\delta}{2^{\mu_{1,1}}},\dots,\frac{k_{1,l_{1}}\delta}{2^{\mu_{1,l_{1}}}},\dots,\frac{k_{x,1}\delta}{2^{\mu_{x,1}}},\dots,\frac{k_{x,l_{x}}\delta}{2^{\mu_{x,l_{x}}}})$$
So we can let
$$w = ( \beta_{1} - \alpha_{1}\bar{d},\dots,\beta_{x} - \alpha_{x}\bar{d}, \frac{\delta}{2},\dots,\frac{\delta}{2}, \frac{\delta}{2},\dots,\frac{\delta}{2},\dots,\frac{\delta}{2},\dots,\frac{\delta}{2}$$
as $w$ is close to the lattice vector $u$, therefore solving a CVP instance with $w$ as the target vector may give us lattice vector $u$ which encodes the secret chunks $d_{j}$ in the $(x+1)^{st}$ to the $(x+m)^{th}$ entries.
Note: x and d are interchanged from the paper, d here is the private key and x here is the number of equations.
- In essence let $$x = (r_{1},\dots, r_{x},d_{1},\dots,d_{m},d_{1,1}\dots,d_{x,l_{1}},\dots,d_{x,l_{x}})$$ We would have, $$xB = u$$
Once the private key is found, we can now send
{"event" : "get_encrypted_flag"}
and can perform AES-CBC decryption on it, you will be left with 10 HP and you get the flag.
Exploit
The flag is bi0sCTF{p4rry_7h15_y0u_f1l7hy_w4r_m4ch1n3}
Aftermath
It looked like the 32 bit seed could be bruteforced and could be solved in an unintended way skipping z3 entirely for the solve (Dorian managed to solve that way) 💀 and Benjamin values not being necessary for the LLL part.