---
# System prepended metadata

title: HKCERT CTF 2025 Writeup - LWECC and Bivariate Copper

---

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}'
```