WEAVE

Reversed a rank-metric Loidreau cryptosystem over GF(2^43) by implementing a Gabidulin code Berlekamp-Welch decoder to decrypt the flag.

CryptographyRank MetricError Correcting CodesUMDCTFHard

"every piece of the cipher lies open on the table. compose them correctly and the flag is yours."

The Challenge: This challenge implements a rank-metric public-key encryption scheme over GF(2⁴³), structurally similar to the Loidreau cryptosystem — a post-quantum proposal based on the hardness of rank-metric decoding. We're given the full private key decomposition (the "loom"), which lets us run a Gabidulin code Berlekamp-Welch decoder to recover the secret and decrypt the flag.

Challenge Files


Scheme Analysis

Parameters

The scheme is instantiated with the following parameters:

Parameter
Value
Description
q
2
Base field size
m
43
Field extension degree → GF(243)
N
40
Codeword length
K
8
Message dimension
FRAYS
5
Noise rank parameter
FIBER_D
3
Shuttle basis dimension

Key Generation

The private key is a triplet decomposition of the public matrix:

warp = knot × loom × shuttle-1

Encryption

bolt = secret × warp + frays

The frays term is the rank-metric "noise" that makes naive inversion hard without the private key decomposition.


The Attack

Our attack follows a structured path to undo the scrambling and decode the ciphertext:

1. Recognise the Gabidulin Structure

We multiply both sides of the encryption equation bolt = secret × warp + frays on the right by the private shuttle matrix:

bolt × shuttle = secret × knot × loom + frays × shuttle

Defining:

The relation becomes:

y = s × loom + e

This is exactly a received word of a Gabidulin[N=40, K=8] code with error e.

2. Bound the Error Rank

The rank of the error vector e = frays × shuttle in GF(2⁴³)⁴⁰ is bounded by:

rank(e) ≤ FRAYS × FIBER_D = 5 × 3 = 15

Since each entry of frays × shuttle is a linear combination over GF(2) of the 15 products {fibers[k] × u[l]}, the maximum rank of e is 15. Since a Gabidulin code of minimum rank distance d = N - K + 1 = 33 can correct up to 16 errors, and 15 ≤ 16, the error is fully decodable.

3. Berlekamp-Welch for Rank-Metric Codes

To find the error support, we search for a monic linearized polynomial Λ of q-degree t ≤ 15 (the error annihilator) such that Λ(e[i]) = 0 for all i. By linearity:

Λ(y[i]) = Λ(s × loom[i]) = P(pegs[i])

where P = Λ ∘ L_s is a composed linearized polynomial of q-degree K + t - 1. This gives the key equation system (setting λ_t = 1):

Σm=0..K+t-1 pm × pegs[i]2m = Σl=0..t λl × y[i]2l

With K + 2t = 38 unknowns and N = 40 equations, this overdetermined system has a unique solution.

4. Extract the Secret

Once p and λ are recovered, we invert the skew-polynomial composition via back-substitution to solve for s₀, s₁, ..., s₇:

pm = Σl=0..min(m,t) λl × sm-l2l

Finally, we recover the original secret vector:

secret = s × knot-1

Solver

Below is the SageMath/Python solver to perform the rank-metric decoding and decrypt the flag:

import json
from hashlib import sha256
from Crypto.Cipher import AES

MOD = (1 << 43) | (1 << 21) | (1 << 3) | (1 << 1) | 1

def gf_mul(a, b):
    r = 0
    while b:
        if b & 1: r ^= a
        b >>= 1
        a <<= 1
        if a >> 43: a ^= MOD
    return r

def gf_inv(a):
    e = (1 << 43) - 2
    r, base = 1, a
    while e:
        if e & 1: r = gf_mul(r, base)
        base = gf_mul(base, base)
        e >>= 1
    return r

def gf_qpow(e, j):
    for _ in range(j): e = gf_mul(e, e)
    return e

def fqm_rref(A, nrows, ncols):
    A = [list(row) for row in A]
    pivots = []
    r = 0
    for c in range(ncols):
        piv = next((i for i in range(r, nrows) if A[i][c]), None)
        if piv is None: continue
        A[r], A[piv] = A[piv], A[r]
        inv_p = gf_inv(A[r][c])
        A[r] = [gf_mul(x, inv_p) for x in A[r]]
        for i in range(nrows):
            if i != r and A[i][c]:
                f = A[i][c]
                A[i] = [A[i][j] ^ gf_mul(f, A[r][j]) for j in range(ncols)]
        pivots.append(c)
        r += 1
    return A, pivots

def fqm_mat_inv(A, n):
    A = [list(row) + [1 if i==j else 0 for j in range(n)] for i, row in enumerate(A)]
    for c in range(n):
        piv = next((r for r in range(c, n) if A[r][c]), None)
        A[c], A[piv] = A[piv], A[c]
        inv_p = gf_inv(A[c][c])
        A[c] = [gf_mul(x, inv_p) for x in A[c]]
        for r in range(n):
            if r != c and A[r][c]:
                f = A[r][c]
                A[r] = [A[r][j] ^ gf_mul(f, A[c][j]) for j in range(2*n)]
    return [row[n:] for row in A]

def mat_mul(A, B, ra, ca, cb):
    C = [[0]*cb for _ in range(ra)]
    for i in range(ra):
        for k in range(ca):
            if not A[i][k]: continue
            for j in range(cb):
                if B[k][j]: C[i][j] ^= gf_mul(A[i][k], B[k][j])
    return C

with open('output.json') as f:
    data = json.load(f)

K, N = 8, 40
bolt   = [int(v) for v in data['bolt']]
pegs   = [int(v) for v in data['loom']['pegs']]
knot   = [[int(v) for v in row] for row in data['loom']['knot']]
shuttle = [[int(v) for v in row] for row in data['loom']['shuttle']]
vault  = data['vault']

# Step 1: compute received word y = bolt × shuttle
y = mat_mul([bolt], shuttle, 1, N, N)[0]

# Step 2: Berlekamp-Welch with t = 15
t = 15
pegs_pow = [[gf_qpow(pegs[i], m) for m in range(K+t)] for i in range(N)]
y_pow    = [[gf_qpow(y[i], l)    for l in range(t+1)] for i in range(N)]

Aeq = [[pegs_pow[i][m] for m in range(K+t)] +
       [y_pow[i][l]    for l in range(t)]
       for i in range(N)]
beq = [y_pow[i][t] for i in range(N)]
Aug = [Aeq[i] + [beq[i]] for i in range(N)]

rref_aug, piv = fqm_rref(Aug, N, K + 2*t + 1)

sol = [0] * (K + 2*t)
for idx, pc in enumerate(piv):
    sol[pc] = rref_aug[idx][K + 2*t]

p_coeffs      = sol[:K+t]
lambda_coeffs = sol[K+t:] + [1]  # λ_t = 1

# Step 3: recover s via triangular back-substitution
lam0_inv = gf_inv(lambda_coeffs[0])
s = [0] * K
for m in range(K):
    rhs = p_coeffs[m]
    for l in range(1, min(m, t) + 1):
        rhs ^= gf_mul(lambda_coeffs[l], gf_qpow(s[m-l], l))
    s[m] = gf_mul(rhs, lam0_inv)

# Step 4: recover secret = s × knot⁻¹
knot_inv = fqm_mat_inv(knot, K)
secret   = mat_mul([s], knot_inv, 1, K, K)[0]

# Step 5: derive key and decrypt
secret_bytes = b''.join(x.to_bytes(6, 'big') for x in secret)
wrap_key = sha256(secret_bytes).digest()[:16]

iv   = bytes.fromhex(vault['iv'])
body = bytes.fromhex(vault['body'])
tag  = bytes.fromhex(vault['tag'])

flag = AES.new(wrap_key, AES.MODE_GCM, nonce=iv).decrypt_and_verify(body, tag)
print(flag.decode())

Dead Ends & Lessons Learned

Null-space projection attempt: I initially tried projecting bolt onto the right null space of warp to isolate frays's column space. However, since the frays products evaluate over GF(2⁴³) instead of GF(2), the null space projections spanned a full 32-dimensional subspace rather than collapsing to rank 5.
Correct Rank Bounding: The parameter FRAYS = 5 represents the rank of frays, not the rank of the final error vector e = frays × shuttle. Because the entries of the shuttle matrix span a 3-dimensional subspace, the final error rank is inflated to 15. Standard Berlekamp-Welch decoding with t = 5 fails due to rank mismatch.

Flag

Decrypted Flag
UMDCTF{l01dr34u_l4mbda3_brick5_th3_w34v3_but_th3_trapd00r_unsp00ls_1t}
zap
TRANSLATOR