Our team (Students Taking Flags United) won the champion in Teritary category in HKCERT CTF 2025. This is the required writeup.
**Crypto - Bivariate copper**
Challenge setup
```python!
from Crypto.Util.number import *
from secret import *
with open("message", "r") as f:
message = f.read().strip().encode()
m = bytes_to_long(flag)
message = bytes_to_long(message)
p = getPrime(1024)
q = getPrime(25)
N = p * q
e = 65537
c = pow(message, e, N)
r1, r2 = getPrime(512), getPrime(512)
k = getPrime(64)
t1 = (k * inverse(m + r1, p)) % p
t2 = (k * inverse(m + r2, p)) % p
leak1 = t1 >> 244
leak2 = t2 >> 244
print(f'{e = }')
print(f'{N = }')
print(f'{c = }')
print(f'{k = }')
print(f'{r1 = }')
print(f'{r2 = }')
print(f'{leak1 = }')
print(f'{leak2 = }')
```
notice that q is only 25 bits!!!
Whats that mean? There is only 2^25 combinations of q, which is around 30millions. So, it is very easy to find the correct value of q and factor N and obtain p.
```
for i in range(1,2**25+1):
if N % i == 0:
print(i) # q = 23520857
```
once obtained q, we have
```
p = N/q = 141728564220215689105745310666761712438508810180329898864435481120368603802944433535439870033233448775648620641750653586896162186017744701345428383181658483867762748650405587002562099473480910200825070136353056453764908432415631471548522147066421239360596574292417840898610873956701534267841521331362184363411
```
Then we look for the second part of the challenge
```python!
t1 = (k * inverse(m + r1, p)) % p
t2 = (k * inverse(m + r2, p)) % p
```
Where k, r1, r2 are known values. But instead of giving us t1 and t2 directly, we only get:
```
leak1 = t1 >> 244
leak2 = t2 >> 244
```
The leaks give us the high-order bits of t1 and t2. This creates a system we can solve using lattice techniques.
`t1 = leak1 × 2^244 + δ1 where δ1 < 2^244 (unknown small error)`
rearranging the above formula will give
`k = t1 × (m + r1) + λ1 × p (for some integer λ1)`
`k = leak1 × 2^244 × m + leak1 × 2^244 × r1 + δ1 × (m + r1) + λ1 × p`
Since δ1 is small and m is relatively small compared to p:
`m × leak1 × 2^244 - λ1 × p + leak1 × 2^244 × r1 ≈ k (ignoring small terms)`
where t2 is similar.
Then it is the time for LLL, like most of the other challenges.
Full solve script:
```python!
from Crypto.Util.number import long_to_bytes, inverse, isPrime
def solve():
e = 65537
N = 3333577291839009732612693330613476891341287017491683764014849337158389717338712200133085615150269196268856288361865352673921704626130772582853528604556994221890454520933132803888321775335519781063447756692130742361931522856942232406992357982482263472763363458621836220024977864980600979194500121897419553619426163227
k = 9352039867057736323
r1 = 10421792656200324147964684790160875926436411483496860422433732508593789212449544620816674407170998779863336939494663076247759140488927744939619406024905901
r2 = 8806088830734144089522276896226392806947836111998696180055727048752624989402057411311728398322297424598954586424896296000606209022432442660527640463521679
leak1 = 4266222222502644630611545246271868348722888987303187402827005454059765428769160822475080050046035916876078546634293907218937483241284454918367519709206766322037148585465519188582916280829212776096606923824120883699251868362915920299645
leak2 = 1176921186497191878459783787148403806360469809421921990427675048480656171919274113895695842508460760829511824635106692634456334400022597605585661597793889066395539405395254174368285751236344600489419240628821864912762242188289636510706
q = 23520857
p = N // q
L1 = leak1 << 244
L2 = leak2 << 244
# Lattice to find m, lambda1, lambda2
# m*L1 - lambda1*p + L1*r1 = delta1
# m*L2 - lambda2*p + L2*r2 = delta2
# Weights
W_delta = 1
W_m = 2^244
W_lambda = 2^244
X = 2^1024 # dummy for target
M = Matrix(ZZ, [
[L1 * W_delta, L2 * W_delta, W_m, 0, 0, 0],
[-p * W_delta, 0, 0, W_lambda, 0, 0],
[0, -p * W_delta, 0, 0, W_lambda, 0],
[L1 * r1 * W_delta, L2 * r2 * W_delta, 0, 0, 0, X]
])
M_lll = M.LLL()
print("LLL done.")
for row in M_lll:
if row[-1] == X or row[-1] == -X:
if row[-1] == -X:
row = -row
# delta1, delta2, m, -lambda1, -lambda2, X
m = abs(row[2]) // W_m
if m == 0: continue
print(f"Candidate m: {m}")
# Verify
t1 = (k * inverse_mod(int(m + r1), int(p))) % p
if (t1 >> 244) == leak1:
print(f"Found flag: {long_to_bytes(int(m))}")
return
solve()
```
flag:
```
Candidate m: 56006392793415003917986111046017601616434940439235635749695606734099817013403284743481843113162926205
Found flag: b'flag{H4hAHhhHh4_c0pP3r_N07_v1OI3n7_3n0uGh}'
```
**Crypto/ LWECC**
By looking at the title we can already tell it is a combination of LWE (Learning With Errors) + ECC (Elliptic-curve cryptography).
task.sage
```python!
from Crypto.Util.number import *
from Crypto.Cipher import AES
from random import choice
from hashlib import md5
from secret import flag
p = 1096126227998177188652856107362412783873814431647
E = EllipticCurve(GF(p), [0, 5])
s = [E.random_element() for _ in range(73)]
e = [E.random_element() for _ in "01"]
A = random_matrix(GF(p), 137, 73)
b = [(sum(i*j for i,j in zip(_,s)) + choice(e)).xy() for _ in A]
print("A =", A.list())
print("b =", b)
print("enc =", AES.new(key=md5(str(s).encode()).digest(), nonce=b"LWECC", mode=AES.MODE_CTR).encrypt(flag))
```
a short code, but tedious to solve.
**Given:**
- Elliptic curve $E: y^2 = x^3 + 5$ over $\mathbb{F}_p$
- Public matrix $A \in \mathbb{F}_p^{137 \times 73}$
- Public vector $B = [B_1, \ldots, B_{137}]$ where $B_i \in E(\mathbb{F}_p)$
- Encrypted flag using AES with key derived from secret $s$
**Encryption relation:**
$$B_i = \sum_{j=1}^{73} A_{ij} \cdot s_j + e_i$$
where:
- $s = [s_1, \ldots, s_{73}]$ is a secret vector of random points on $E$
- $e_i \in \{e_0, e_1\}$ is an error term (one of two random curve points)
- Operations are in the elliptic curve group $E(\mathbb{F}_p)$
When we look at the Elliptic curve, we can see that it is anomalous (trace-one) curve. Whats that mean? This mean the order of the curve is exactly equal to the field size p. which could be confirmed by
```python!
E.order()==p
```
So what does that mean even though we know it is an anomalous curve?
When $\#E(\mathbb{F}_p) = p$, we can construct an **isomorphism** between:
- The elliptic curve group $E(\mathbb{F}_p)$ (additive group of order $p$)
- The field $\mathbb{F}_p$ (additive group of order $p$)
A non-canonical group homomorphism inducing an isomorphism after normalization.
This isomorphism transforms elliptic curve problems into **linear algebra problems** over $\mathbb{F}_p$.
The isomorphism is constructed using **p-adic logarithms**. The map $\phi: E(\mathbb{F}_p) \to \mathbb{F}_p$ satisfies:
$$\phi(P + Q) = \phi(P) + \phi(Q)$$
This is a **group homomorphism**, and for anomalous curves, it's an isomorphism (bijective).
The **p-adic numbers** $\mathbb{Q}_p$ are a completion of $\mathbb{Q}$ with respect to the p-adic metric rather than the usual absolute value metric.
**p-adic valuation:** For $x \in \mathbb{Q}_p \setminus \{0\}$, write $x = p^n \cdot u$ where $u$ is a p-adic unit. Then $v_p(x) = n$ is the p-adic valuation.
**Key property:** Elements close to 0 in the p-adic metric are those divisible by high powers of $p$.
We lift the curve $E/\mathbb{F}_p$ to a curve $\tilde{E}/\mathbb{Q}_p$ with the same coefficients:
- Original: $E: y^2 = x^3 + 5$ over $\mathbb{F}_p$
- Lifted: $\tilde{E}: y^2 = x^3 + 5$ over $\mathbb{Q}_p$
For a point $P = (x, y) \in E(\mathbb{F}_p)$, we can lift it to $\tilde{P} = (x, y) \in \tilde{E}(\mathbb{Q}_p)$ where we view $x, y$ as elements of $\mathbb{Z}_p$ (the p-adic integers).
For an anomalous curve, the map is:
$$\phi(P) = \frac{-x(pP)}{y(pP)} \bmod p$$
where $pP$ denotes the $p$-multiple of the lifted point $\tilde{P}$ on $\tilde{E}(\mathbb{Q}_p)$.
The formal group law near the identity can be written in coordinates $(t)$ where $t = -x/y$ for points near $O$. The group law becomes:
$$t(P + Q) = t(P) + t(Q) + O(t^2)$$
For $pP$, where $P$ is a point of exact order $p$, the higher-order terms vanish modulo $p$, giving us an exact homomorphism.
**Attack Strategy**
Apply the isomorphism $\phi: E(\mathbb{F}_p) \to \mathbb{F}_p$ to the encryption equation:
$$B_i = \sum_{j=1}^{73} a_{ij} s_j + \epsilon_i$$
Apply $\phi$ to both sides:
$$\phi(B_i) = \phi\left(\sum_{j=1}^{73} a_{ij} s_j + \epsilon_i\right)$$
Since $\phi$ is a group homomorphism:
$$\phi(B_i) = \sum_{j=1}^{73} a_{ij} \phi(s_j) + \phi(\epsilon_i)$$
**Define:**
- $y_i = \phi(B_i)$
- $x_j = \phi(s_j)$
- $\eta_i = \phi(\epsilon_i) \in \{\eta_0, \eta_1\}$ where $\eta_k = \phi(e_k)$
We now have a **linear system over $\mathbb{F}_p$**:
$$y = Ax + \eta$$
where $\eta$ is a noise vector with each component in $\{\eta_0, \eta_1\}$.
We have:
- Unknown: $x \in \mathbb{F}_p^{73}$ (the secret)
- Unknown: $\eta_0, \eta_1 \in \mathbb{F}_p$ (the error values)
- Unknown: The choice of which $\eta_k$ is used for each position
**Key insight:** Since $A$ is $137 \times 73$, the system is **overdetermined**. The kernel of $A$ is non-trivial.
Compute the **left kernel** of $A$:
$$V \in \mathbb{F}_p^{k \times 137} \quad \text{where } VA = 0$$
Multiply both sides by $V$:
$$Vy = V(Ax + \eta) = 0 + V\eta = V\eta$$
The unknown secret $x$ has been eliminated!
The error vector $\eta$ has a specific structure. Let $C \in \{0,1\}^{137}$ be the **error choice vector**:
$$C_i = \begin{cases} 0 & \text{if } \eta_i = \eta_0 \\ 1 & \text{if } \eta_i = \eta_1 \end{cases}$$
Then:
$$\eta = \eta_0 \mathbf{1} + (\eta_1 - \eta_0) C$$
where $\mathbf{1} = (1, 1, \ldots, 1)^T$.
Substituting:
$$V\eta = \eta_0 V\mathbf{1} + (\eta_1 - \eta_0) VC$$
**Define:**
- $\gamma = Vy$ (known)
- $u = V\mathbf{1}$ (known)
- $W = V$ (known matrix)
We get:
$$\gamma = \eta_0 u + (\eta_1 - \eta_0) WC$$
We still have two unknown scalars $\eta_0, \eta_1$. To eliminate them, we project orthogonally to both $u$ and $\gamma$.
Consider the $2 \times k$ matrix:
$$M = \begin{bmatrix} \gamma^T \\ u^T \end{bmatrix}$$
Find its left kernel (a subspace of dimension $k-2$):
$$P \in \mathbb{F}_p^{(k-2) \times k} \quad \text{where } PM = 0$$
This means $P\gamma = 0$ and $Pu = 0$.
Multiply our equation by $P$:
$$P\gamma = \eta_0 Pu + (\eta_1 - \eta_0) PWC$$
$$0 = 0 + (\eta_1 - \eta_0) PWC$$
Since generically $\eta_1 \neq \eta_0$, we have:
$$PWC = 0 \pmod{p}$$
We now have a system:
$$MC = 0 \pmod{p}$$
where:
- $M = PW$ is a known $(k-2) \times 137$ matrix over $\mathbb{F}_p$
- $C \in \{0,1\}^{137}$ is unknown
- The equation holds modulo $p$
This is a **hidden subset sum problem** or **modular knapsack**.
**Lattice formulation:**
Build a lattice basis:
$$L = \begin{bmatrix} I_{137} & M^T \cdot s \\ 0 & p \cdot s \cdot I_{k-2} \end{bmatrix}$$
where $s$ is a scaling factor (typically $s = p$ or $s = 1$).
A short vector in this lattice corresponds to a solution $C$:
- First 137 coordinates: the binary vector $C$
- Last $k-2$ coordinates: should be $\approx 0$ (due to the constraint $MC \equiv 0$)
**LLL reduction** finds short vectors. The shortest vector often has:
- Binary entries in the first 137 positions
- Small/zero entries in the last $k-2$ positions
(lol, so it is LLL again)
Once we have $C$:
1. **Recover $\eta_0, \eta_1$:** Use two linearly independent equations from $\gamma = \eta_0 u + (\eta_1 - \eta_0) WC$
2. **Reconstruct $\eta$:** Build the full error vector
$$\eta_i = \begin{cases} \eta_0 & \text{if } C_i = 0 \\ \eta_1 & \text{if } C_i = 1 \end{cases}$$
3. **Solve for $x$:** From $y = Ax + \eta$, we get
$$Ax = y - \eta$$
Solve this linear system (possible since we have more equations than unknowns)
4. **Invert the logarithm map:** We have $x_j = \phi(s_j)$. To recover $s_j$:
- Find a generator $G \in E(\mathbb{F}_p)$ with $\phi(G) \neq 0$
- Compute $k_j = x_j / \phi(G)$
- Then $s_j = k_j \cdot G$
5. **Decrypt:** Compute `key = md5(str(s))` and decrypt the flag
full solve script:
```python!
import ast
import re
from sage.all import *
from hashlib import md5
from Crypto.Cipher import AES
def parse_output(filename):
with open(filename, 'r') as f:
content = f.read()
a_match = re.search(r'A = (\[.*?\])', content, re.DOTALL)
A_list = ast.literal_eval(a_match.group(1))
b_match = re.search(r'b = (\[.*?\])', content, re.DOTALL)
b_list = ast.literal_eval(b_match.group(1))
enc_match = re.search(r'enc = (b[\'"].*?[\'"])', content)
if not enc_match:
enc_match = re.search(r'enc = (.*)', content)
enc_val = eval(enc_match.group(1))
return A_list, b_list, enc_val
def find_smart_log_func(E):
p = E.base_ring().order()
Q = Qp(p, 20)
base_invs = [ZZ(x) for x in E.a_invariants()]
for diff in [p, 2*p, 3*p]:
invs = list(base_invs)
invs[3] += diff
print(f"Trying perturbation a4 += {diff}")
try:
Eq = EllipticCurve(Q, invs)
except:
continue
def log_candidate(P):
if P == E(0): return 0
x_P = ZZ(P.xy()[0])
y_P = ZZ(P.xy()[1])
try:
P_lift = Eq.lift_x(x_P, all=True)[0]
except:
return None
if (P_lift.xy()[1] - y_P).valuation() < 1:
P_lift = -P_lift
R = p * P_lift
if R == Eq(0):
return None
x_R, y_R = R.xy()
val = -x_R / y_R
v = val.valuation()
if v < 1: return None
if v >= 2:
res = val / p**2
else:
res = val / p
return GF(p)(res.residue())
P1 = E.random_element()
P2 = E.random_element()
P3 = P1 + P2
l1 = log_candidate(P1)
l2 = log_candidate(P2)
l3 = log_candidate(P3)
if l1 is None or l2 is None or l3 is None: continue
if l1 == 0 and l2 == 0: continue
if (l1 + l2 - l3) == 0:
print(f"Found working Smart Log with perturbation {diff}")
return log_candidate
raise Exception("Could not find working Smart Log map")
def solve():
print("Parsing data...")
A_flat, b_coords, enc = parse_output("output.txt")
p = 1096126227998177188652856107362412783873814431647
E = EllipticCurve(GF(p), [0, 5])
rows = 137
cols = 73
A_matrix = Matrix(GF(p), rows, cols, A_flat)
B_points = [E(x, y) for x, y in b_coords]
print("Finding Smart Log function...")
try:
smart_log = find_smart_log_func(E)
except Exception as e:
print(e)
return
print("Computing logs...")
y_vec_vals = []
for i, P in enumerate(B_points):
y_vec_vals.append(smart_log(P))
y_vec = vector(GF(p), y_vec_vals)
print("Computing kernel...")
K = A_matrix.kernel()
V = K.basis_matrix()
gamma = V * y_vec
u = V * vector(GF(p), [1]*rows)
W = V
M_proj = matrix([gamma, u]).transpose()
P_mat = M_proj.kernel().basis_matrix()
M_lat_base = P_mat * W
print("Building Lattice...")
dim = 137 + M_lat_base.nrows()
L = Matrix(ZZ, dim, dim)
# Use scaling
scale = p # Large scaling factor
for i in range(137):
L[i, i] = 1
col = M_lat_base.column(i)
for j in range(M_lat_base.nrows()):
L[i, 137+j] = int(col[j]) * scale
# Modulus part
for j in range(M_lat_base.nrows()):
L[137+j, 137+j] = p * scale
print("Running LLL...")
L_red = L.LLL()
C_found = None
for row in L_red:
# Check if last part is 0 (since scaled, it should be exactly 0)
is_zero = True
for k in range(137, dim):
if row[k] != 0:
is_zero = False
break
if is_zero:
cand = row[:137]
if all(x in [0, 1] for x in cand) and sum(cand) > 0:
print("Found binary C!")
C_found = vector(GF(p), cand)
break
elif all(x in [0, -1] for x in cand) and sum(cand) < 0:
print("Found negative binary C!")
C_found = vector(GF(p), [-x for x in cand])
break
if C_found is None:
print("C not found via LLL.")
# Debug output
for i in range(5):
print(f"Row {i} norm: {float(L_red[i].norm())}")
return
v1 = u
v2 = W * C_found
eta0 = 0
delta_eta = 0
solved = False
for i in range(64):
for j in range(i+1, 64):
mat = matrix(GF(p), [[v1[i], v2[i]], [v1[j], v2[j]]])
if mat.det() != 0:
rhs = vector(GF(p), [gamma[i], gamma[j]])
sol = mat.solve_right(rhs)
eta0 = sol[0]
delta_eta = sol[1]
solved = True
break
if solved: break
eta1 = eta0 + delta_eta
print(f"eta0: {eta0}")
print(f"eta1: {eta1}")
E_vals = []
for k in range(137):
if C_found[k] == 0:
E_vals.append(eta0)
else:
E_vals.append(eta1)
E_vec = vector(GF(p), E_vals)
rhs = y_vec - E_vec
s_log = A_matrix.solve_right(rhs)
print("Recovered s_log.")
while True:
G = E.random_element()
lg = smart_log(G)
if lg != 0:
break
inv_lg = 1/lg
s_points = []
for val in s_log:
k = val * inv_lg
k_int = int(k)
s_points.append(k_int * G)
key = md5(str(s_points).encode()).digest()
cipher = AES.new(key=key, nonce=b"LWECC", mode=AES.MODE_CTR)
try:
flag = cipher.decrypt(enc)
print("Flag:", flag)
except:
print("Decryption failed")
solve()
```
The script take around a few minutes to run
```
(base) sage solver.sage
Parsing data...
Finding Smart Log function...
Trying perturbation a4 += 1096126227998177188652856107362412783873814431647
Found working Smart Log with perturbation 1096126227998177188652856107362412783873814431647
Computing logs...
Computing kernel...
Building Lattice...
Running LLL...
Found negative binary C!
eta0: 909150034385859605294145899352651824206394192717
eta1: 1015467823800812299882714936635122995816109565601
Recovered s_log.
Flag: b'flag{deb0b9c915b92dea9db131becdf306}'
```