EC-DH-AES
- Category: Crypto
Description
I encrypted the flag with the flag and added a xor for extra security!
Challenge by Eli Kaski
Sources were attached.
Solution
We are greeted with a Python script implementing a custom Elliptic Curve Diffie-Hellman (ECDH) key exchange. The server generates a private key, shouts its public x-coordinate at us, and uses the derived shared secret to encrypt the flag using AES-ECB.
First things first, let’s talk to the server and see what we’re dealing with.
nc 0.cloud.chals.io 18923
Server Output:
2025-12-08T15:44:17
Here is your private key, keep it a secret! 0x442b9e462d569d148005e963485f2391250198bdf96ae294bcf8793966948d8
Here is the server's public key, you can do whatever you want with it! 0x4d9174298ca01182f367a9d34769c2c7d2547352af052ef957e300ffa296144f
And here is the encrypted flag, enjoy! b'\xb6,\xa5\xef\xea\x98\xe0\x16\x10\n\xb3\xd7LP)\xafw\x11\x16\xb9\xe5P\x9b\xf8\x98\xa5\xae\xc7\xa9\x8e\x14\xfe|\x83\xb1\xfc\to\xd7\xacY}\x9eh\x0c3\x13R'
Goodbye!
So we have:
- Our Private Key: Thanks, I guess?
- Server’s Public X: Just $x$. Because giving us $y$ would make life too easy (spoiler: it doesn’t help them).
- Encrypted Flag: The prize.
We took a look at how the server generates its private key. It’s… interesting.
m = hashlib.sha1()
m.update(long_to_bytes(int(datetime.now().timestamp())))
m.update(os.urandom(4))
m.update(FLAG)
server_private_key = bytes_to_long(m.digest())
# (from chal.py)
Hold up. SHA-1? In 2025? Besides being cryptographically retired, SHA-1 outputs exactly 160 bits. However, the elliptic curve is defined over a 256-bit field ($p \approx 2^{256}$).
This means the private key $d_S$ is surprisingly small: \(d_S < 2^{160}\)
This 96-bit gap is going to be their downfall.
2. The “Smooth” Operator
Next, I asked SageMath to check out the curve.
- Is it anomalous? (Trace = 1). No.
- Is the order prime? (Safe). No.
Let’s factor the number of points on the curve (Order $N$):
E = EllipticCurve(GF(p), [a, b])
print(factor(E.order()))
The challenge’s EC scalar multiplication (double-and-add):
def point_multiplication(P, n):
Q = P
R = O
while n > 0:
if n % 2 == 1:
R = point_addition(R, Q)
Q = point_addition(Q, Q)
n = n // 2
assert check_point(R)
return R
# (from chal.py)
Result: \(N = 11 \times 53 \times 13007 \times \dots \times p_{large}\)
The order is Smooth. It’s made up of a bunch of tiny primes and one “large” prime ($p_{large} \approx 106$ bits).
Exploitation Strategy
Usually, with a smooth order, we use the Pohlig-Hellman algorithm to solve the Discrete Logarithm Problem (DLP) instantly. However, there’s that one 106-bit prime factor. Solving DLP for 106 bits takes about $2^{53}$ operations. My laptop would finish that calculation sometime around the heat death of the universe (or at least until the CTF ends).
We don’t need to solve the DLP for the big factor. We just need to recover the private key. Remember the SHA-1 issue? The key is only 160 bits.
- Calculate Modulus: We multiply all the tiny prime factors together. Let’s call this product $M$. \(M = \prod_{small} p_i \approx 2^{150}\)
- Partial Recovery: We use Pohlig-Hellman on just the small factors. This is instant. Using the Chinese Remainder Theorem (CRT), we get: \(d_{part} \equiv d_S \pmod M\)
- Brute Force the Rest: We know $d_S \approx 160$ bits. We know $d_S \pmod{\approx 150 \text{ bits}}$. The missing information is only about 10 bits. \(d_S = d_{part} + k \cdot M\) I can brute force $2^{10}$ (1024) iterations before my coffee finishes brewing.
Solution Script
Run this script using SageMath.
from sage.all import *
import socket
import ast
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad
from Crypto.Util.number import long_to_bytes
# ...existing code...
# The challenge's AES key derivation and encryption:
shared_point = point_multiplication(server_public_key, client_private_key)
AES_key = long_to_bytes(shared_point.x ^ server_private_key)
cipher = AES.new(AES_key, AES.MODE_ECB)
ciphertext = cipher.encrypt(pad(FLAG, 16))
# (from chal.py)
# --- Challenge Parameters ---
# "Standard" custom curve constants
p = 0xef15044248691197b46bf7c58045ca09919237c0acb7e10b0b7a10115e33bacf
a = 0x7c7ab0de24a6d60f0cf17c4cbdb25c23b4e22b5f6a88bd79894ef44cd0375dff
b = 0xf45b986d9c01a78504a95af55035e336a5a7acfa33e700595502af80a6a7fbbd
Gx = 0x90f7928935a959dea114d3efcf44272a8a39c26c8311ac1ee3771878ffdcd1a9
Gy = 0xd3f156fc5c525a91b5c9ae985e666d1349a170cae3f81658f28210b0270bf1fe
def solve():
print("[*] Connecting to server...")
try:
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect(('0.cloud.chals.io', 18923))
f = s.makefile('r')
except Exception as e:
print(f"[-] Connection failed: {e}")
return
# 1. Parse Output
f.readline() # Timestamp
line_priv = f.readline().strip()
line_pub = f.readline().strip()
line_flag = f.readline().strip()
client_priv_key = int(line_priv.split('! ')[1], 16)
server_pub_x = int(line_pub.split('! ')[1], 16)
encrypted_flag = ast.literal_eval(line_flag.split('! ')[1])
print(f"[*] Client Private Key: {hex(client_priv_key)}")
print(f"[*] Server Public X: {hex(server_pub_x)}")
# 2. Setup Curve
F = GF(p)
E = EllipticCurve(F, [a, b])
G = E(Gx, Gy)
# 3. Factor Order and Filter Small Factors
print("[*] Calculating Curve Order and Factors...")
order = E.order()
factors_list = list(factor(order))
# We ignore the massive 106-bit factor. We ain't got time for that.
# We keep everything under 60 bits.
small_factors = [fac for fac in factors_list if fac[0] < 2**60]
# Calculate the modulus M (product of small factors)
M = 1
for fac, exp in small_factors:
M *= (fac ** exp)
print(f"[*] Solving DLP modulo M (approx {M.nbits()} bits)...")
print(f"[*] Gap to bridge: {160 - M.nbits()} bits (Easy mode)")
# 4. Recover Private Key
# We have to try both Y solutions because square roots are annoying like that.
rhs = (pow(server_pub_x, 3, p) + a*server_pub_x + b) % p
y1 = pow(rhs, (p+1)//4, p)
y2 = p - y1
server_priv_key = None
Qs = None
for y_cand in [y1, y2]:
try:
Q = E(server_pub_x, y_cand)
residues, moduli = [], []
valid_subgroups = True
# Step A: Pohlig-Hellman on the easy factors
for fac, exp in small_factors:
subgroup_order = fac ** exp
cofactor = order // subgroup_order
Gi = cofactor * G
Qi = cofactor * Q
try:
# 'operation=+' because Elliptic Curves are additive groups
d_i = discrete_log(Qi, Gi, ord=subgroup_order, operation='+')
residues.append(d_i)
moduli.append(subgroup_order)
except ValueError:
valid_subgroups = False
break
if not valid_subgroups: continue
# Step B: Chinese Remainder Theorem
d_part = crt(residues, moduli)
# Step C: Brute Force the remaining few bits
print(f"[*] Partial key found. Brute-forcing the gap...")
for k in range(5000):
cand = d_part + k * M
if cand * G == Q:
print(f"[+] Found Server Private Key: {hex(cand)}")
server_priv_key = cand
Qs = Q
break
if server_priv_key: break
except: continue
if server_priv_key is None:
print("[-] Failed to recover key. Maybe the math gods are angry today.")
return
# 5. Decrypt the loot
S = int(client_priv_key) * Qs
shared_x = int(S[0])
aes_key = long_to_bytes(shared_x ^ server_priv_key).rjust(32, b'\x00')
try:
cipher = AES.new(aes_key, AES.MODE_ECB)
flag = unpad(cipher.decrypt(encrypted_flag), 16)
print(f"\n[+] FLAG: {flag.decode()}")
except Exception as e:
print(f"[-] Decryption failed: {e}")
if __name__ == "__main__":
solve()
Result
[*] Connecting to server...
[*] Client Private Key: 0xbcf85289b396df47a581867db91eae1d9e514b255c5d98fefca2d4d26dc3f9db
[*] Server Public X: 0xc14cbe707949156f5788f26287fd39b4dab2e089c4e29b608a9e466b2151269
[*] Calculating Curve Order and Factors...
[*] Factors: [(11, 1), (53, 1), (13007, 1), (21649, 1), (36833, 1), (284707, 1), (71811187901, 1), (13208374537727, 1), (66225572278203700716602526903197, 1)]
[*] Solving DLP modulo M (approx 151 bits)...
[*] Missing information gap: 9 bits (Brute-forceable)
[*] Partial key recovered: 1007972570581662408203936978863171057786114881 (mod 1632902521996322860336660619581050450945797953)
[*] Brute-forcing remaining bits...
[*] Partial key recovered: 624929951414660452132723640717879393159683072 (mod 1632902521996322860336660619581050450945797953)
[*] Brute-forcing remaining bits...
[+] Found Server Private Key: 0xcdc28e3cef1ac22573a14452d92410ff1173938f
[+] FLAG: BSidesTLV2025{P0hL1g-H3llm@N_W1th_@_Tw1St!}