Challenge Overview
- CTF: Pioneers25
- Challenge: PBTF 3
- Category: Cryptography
- Author: a_b
- Files:
server.py - Connection info:
nc 134.112.83.77 1005 - Description:
Note
Now this is the final challenge . if you solve it you are a true curves master
Introduction
This is the third and final part of the elliptic-curve challenges series PBTF from Pioneers25. Just like part 2, the curve is still standard P-256. The difference here is that the server does not generate a tiny nonce from the chosen message. Instead, it generates a short list of nonces once at the beginning of the session and then keeps choosing from that same list again and again. In this writeup we show how singning 2 messages with the same nonce immediately reveal the private key.
If you want the slower background on elliptic curves, ECDSA, and why the nonce is such a critical value, read the first part first.
Part 2 is also useful before reading this one, because it shows another way bad nonces break the same signature scheme.
Initial Analysis
server.py
from Crypto.Util.number import inverse ,bytes_to_long,long_to_bytes
from fastecdsa.curve import P256 as EC
from fastecdsa.point import Point
import random, hashlib
from redacted import *
class ECDSA:
def __init__(self):
self.G = Point(EC.gx, EC.gy, curve=EC)
self.order = EC.q
self.privkey = random.randrange(1, self.order - 1)
self.pubkey = (self.privkey * self.G)
def info(self):
print(info.format(curve=EC, pubkey=self.pubkey))
def ECDSA_sign(self, message):
k = random.choice(k_list)
r = (k*self.G).x % self.order
s = inverse(k, self.order) * (h(message) + r * self.privkey) % self.order
return (r, s)
def ECDSA_verify(self, message, r, s):
r %= self.order
s %= self.order
if s == 0 or r == 0:
return False
s_inv = inverse(s, self.order)
u1 = (h(message)*s_inv) % self.order
u2 = (r*s_inv) % self.order
W = u1*self.G + u2*self.pubkey
return W.x == r
def h(message):
return bytes_to_long(hashlib.sha256(message).digest()[:8])
def k_gen(name):
global k_list
k_list=[h(long_to_bytes(random.randrange(h(name)))) for _ in range(10)]
if __name__ == "__main__":
ECDSA = ECDSA()
print('Identify yourself')
name = input("> ").strip().encode()
k_gen(name)
print(menu)
for _ in range (10):#limited attempts no bruteforce required
try:
print("Choose an option:")
choice = input("> ").strip()
if not choice.isdigit():
print("Please enter a number.")
continue
if choice == '1':
r=int(input("r: ").strip())
s=int(input("s: ").strip())
if ECDSA.ECDSA_verify(b'LET ME IN !!!', r, s):
print("Valid signature!")
print('Welcome ', name.decode())
print(f"Here is your flag: {FLAG}")
else:
print("Invalid signature!")
elif choice == '2':
print('Provide the message to sign')
message = input("> ").strip().encode()
if message==b'LET ME IN !!!':
print("You are not allowed to sign this message!")
continue
r, s = ECDSA.ECDSA_sign(message)
print('Signature: ({},{})'.format(r, s))
elif choice == '3':
ECDSA.info()
elif choice == '4':
print("Exiting...")
break
else:
print("Invalid choice!")
print()
except KeyboardInterrupt:
print("\nForcing exit :(")
break
except Exception as e:
print("Error:", e)
print("Please try again.")
print()As in part 2, the service uses standard P-256:
from fastecdsa.curve import P256 as ECSo again the interesting part is not the curve itself, but the nonce generation.
The signing code is:
def ECDSA_sign(self, message):
k = random.choice(k_list)
r = (k*self.G).x % self.order
s = inverse(k, self.order) * (h(message) + r * self.privkey) % self.order
return (r, s)The important part is random.choice(k_list), because it means the nonce is not generated fresh every time. It comes from a list that was prepared earlier.
So the next place to inspect is how that list is built:
def k_gen(name):
global k_list
k_list=[h(long_to_bytes(random.randrange(h(name)))) for _ in range(10)]At first and like it was the case with the part 2, this function also looks a bit strange, so it is better to read it slowly.
The variable is called name, and this time it really is the user name. The server calls k_gen(name) once, right after the connection starts.
Then this line:
random.randrange(h(name))picks a random integer between 0 and h(name) - 1.
That random integer is turned into bytes, hashed, and stored in the list:
h(long_to_bytes(random.randrange(h(name))))And because this happens inside:
[... for _ in range(10)]the result is a list with 10 values.
So this function builds a list of 10 possible nonces at the start of the session.
After that, every signature simply does:
k = random.choice(k_list)So the server is not choosing from the full ECDSA nonce space anymore. It is choosing from a fixed list of only 10 values.
Now we can see why this looks suspicious.
Why repeated nonces are dangerous
Suppose two different messages \(m_1\) and \(m_2\) are signed using the same nonce \(k\) .
Then both signatures share the same point:
\[ R = kG \]and therefore they also share the same value:
\[ r = x(R) \bmod n \]If the two signatures are \((r, s_1)\) and \((r, s_2)\) , then:
\[ s_1 = k^{-1}(h(m_1) + dr) \pmod n \] \[ s_2 = k^{-1}(h(m_2) + dr) \pmod n \]Subtracting gives:
\[ s_1 - s_2 = k^{-1}(h(m_1) - h(m_2)) \pmod n \]so:
\[ k = \frac{h(m_1) - h(m_2)}{s_1 - s_2} \pmod n \]and after that:
\[ d = \frac{s_1k - h(m_1)}{r} \pmod n \]So once a repeated nonce appears, the private key is gone.
That is exactly the attack path in this challenge.
What the Server Lets Us Do
The interaction is the same basic pattern as before:
- request a signature on any message except
LET ME IN !!! - submit a signature for the target message
- read public information
So the flow is clear:
- ask for signatures on many chosen messages
- wait for the same
rvalue to appear twice - recover the private key
- forge the target signature
The only thing to pay attention to is the menu limit. The service gives only 10 actions per session, so there is no room to waste requests. Therefore, it is time for exploitation:

Exploitation
At this point the challenge is about waiting for the nonce to be repeated.
Step 1: Force a repeated r
Because r comes from the point kG, two signatures that use the same nonce must have the same r.
So the natural thing to do is to keep asking for signatures on distinct messages and watch the returned r values.
The exploit uses:
msg0msg1msg2- …
and stores every tuple (message, r, s).
Why distinct messages? Because when the repeated nonce formulas are used, we need different message hashes. Using different messages guarantees that.
The service allows only 10 actions total, so what we can do is:
- 9 signature queries
- 1 final submit
That is still plenty, because the nonces are being chosen from a pool of only 10 values. So a collision is very likely. Once a repeated r shows up, we already solved the challenge.
Step 2: Recover the private key
Suppose the same r appears twice:
(r, s_1)onm_1(r, s_2)onm_2
Then the repeated-nonce equations apply directly:
\[ k = \frac{h(m_1) - h(m_2)}{s_1 - s_2} \pmod n \]and:
\[ d = \frac{s_1k - h(m_1)}{r} \pmod n \]That is exactly what we compute:
k_value = ((h(old_message) - h(message)) * pow((old_s - s_value) % N, -1, N)) % N
private_key = ((old_s * k_value - h(old_message)) * pow(r_value, -1, N)) % NOnce the repeated nonce is found, the private key is recovered exactly.
Step 3: Forge the target signature
After that, the rest is just standard ECDSA forging with a known private key.
As in part 2, the cleanest choice is to set:
\[ k = 1 \]Then:
- \(R = G\)
- \(r = x(G) \bmod n\)
- \(k^{-1} = 1\)
and so:
\[ s = h(m) + dr \pmod n \]That gives a valid signature for the forbidden message LET ME IN !!!.
Step 4: Match the server hash
As usual, only the first 8 bytes of SHA-256 are used:
def h(message):
return bytes_to_long(hashlib.sha256(message).digest()[:8])Step 5: Submit the signature
Once the private key is known and the target signature is forged, the last step is just to send r and s back to the service.
If everything matches, the server accepts the forged signature and returns the flag.
Recap
Putting everything together, the attack steps are:
- Connect to the service.
- Request signatures on distinct messages.
- Watch the
rvalues. - As soon as one
rrepeats, recover the nonce. - Recover the private key.
- Forge a signature for
LET ME IN !!!. - Submit it and recover the flag.
If a session somehow fails to produce a repeated r within the available signing requests, just reconnect and try again. But it is likely to start working from the first try.
Full Solve Script
#!/usr/bin/env python3
import hashlib
import re
from pwn import context, remote
HOST = "134.112.83.77"
PORT = 1005
P = 0xFFFFFFFF00000001000000000000000000000000FFFFFFFFFFFFFFFFFFFFFFFF
A = (P - 3) % P
GX = 0x6B17D1F2E12C4247F8BCE6E563A440F277037D812DEB33A0F4A13945D898C296
GY = 0x4FE342E2FE1A7F9B8EE7EB4A7C0F9E162BCE33576B315ECECBB6406837BF51F5
N = 0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551
G = (GX, GY)
TARGET = b"LET ME IN !!!"
context.log_level = "error"
def h(message: bytes) -> int:
return int.from_bytes(hashlib.sha256(message).digest()[:8], "big")
def point_add(point1, point2):
if point1 is None:
return point2
if point2 is None:
return point1
x1, y1 = point1
x2, y2 = point2
if x1 == x2:
if (y1 + y2) % P == 0:
return None
slope = ((3 * x1 * x1 + A) * pow((2 * y1) % P, -1, P)) % P
else:
slope = ((y2 - y1) * pow((x2 - x1) % P, -1, P)) % P
x3 = (slope * slope - x1 - x2) % P
y3 = (slope * (x1 - x3) - y1) % P
return (x3, y3)
def scalar_mult(scalar: int, point):
result = None
addend = point
while scalar:
if scalar & 1:
result = point_add(result, addend)
addend = point_add(addend, addend)
scalar >>= 1
return result
def recover_private_key(signatures):
seen = {}
for message, r_value, s_value in signatures:
if r_value not in seen:
seen[r_value] = (message, s_value)
continue
old_message, old_s = seen[r_value]
k_value = ((h(old_message) - h(message)) * pow((old_s - s_value) % N, -1, N)) % N
private_key = ((old_s * k_value - h(old_message)) * pow(r_value, -1, N)) % N
return private_key
raise RuntimeError("no repeated nonce found")
def forge_signature(private_key: int):
r_value = GX % N
s_value = (h(TARGET) + r_value * private_key) % N
if s_value == 0:
raise RuntimeError("unexpected zero s value")
return r_value, s_value
def solve_once():
io = remote(HOST, PORT)
try:
io.recvuntil(b"> ")
io.sendline(b"solver")
io.recvuntil(b"Choose an option:")
signatures = []
for index in range(9):
message = f"msg{index}".encode()
io.sendline(b"2")
io.recvuntil(b"> ")
io.sendline(message)
output = io.recvuntil(b"Choose an option:").decode("utf-8", "replace")
r_value, s_value = map(int, re.search(r"Signature: \((\d+),(\d+)\)", output).groups())
signatures.append((message, r_value, s_value))
repeated = len({r for _, r, _ in signatures}) != len(signatures)
if repeated:
break
private_key = recover_private_key(signatures)
forged_r, forged_s = forge_signature(private_key)
io.sendline(b"1")
io.recvuntil(b"r:")
io.sendline(str(forged_r).encode())
io.recvuntil(b"s:")
io.sendline(str(forged_s).encode())
return io.recvuntil((b"}", b"Choose an option:")).decode("utf-8", "replace")
finally:
io.close()
def main():
while True:
result = solve_once()
print(result, end="")
if "Pioneers25{" in result:
return
if __name__ == "__main__":
main()Running the exploit returns:
Pioneers25{h3r3_c0m35_7h3_n3x7_Bruce_schneier}
I hope this series was fun, it was a nice and fun CTF overall and it was good to play some crypto once again after a few months of relying entirely on AI agents. Take care homies and see you on the next blog ( hopefully it will be another ECC writeup )
