Weave / ph1nx_ Writeup
Flag
UMDCTF{l01dr34u_l4mbda3_brick5_th3_w34v3_but_th3_trapd00r_unsp00ls_1t}
What the challenge is doing
The challenge builds a secret key from a hidden vector over the field , then uses that key to AES-GCM encrypt the flag.
The important parts are:
- A public Gabidulin-style linear map generated from
pegs. - A secret
knotmatrix that mixes the secret. - A
shuttlematrix that is built from a 3-dimensional fiber subspace. - A ciphertext in
vaultencrypted withSHA-256(secret_bytes)[:16].
The job is to recover the secret vector, derive the AES key, and decrypt the vault.
The structure hidden in the output
The generator does this:
- It samples
pegs, a length-40 list of independent field elements. - It builds a
loommatrix from Frobenius powers of those pegs. - It samples a random invertible
knotmatrix of size8 x 8. - It samples a 3-dimensional space
fibersand uses it to buildshuttle. - It computes
and then publishes warp, bolt, loom, and vault.
The secret is hidden in
where frays is a low-rank perturbation coming from a 5-dimensional random space.
Why the flag is recoverable
The key observation is that the overall construction is still linear over the base field. The shuttle matrix compresses the random frays into a rank-limited additive disturbance, while the loom matrix is a Gabidulin encoding over .
So the attack is not brute force. The recoverable structure is:
- Use the published
shuttleto move the received word into the code domain. - Treat the result as a Gabidulin codeword plus a bounded-rank error.
- Decode the message polynomial.
- Undo
knotto recover the secret vector. - Hash the recovered secret bytes to get the AES key.
- Decrypt
vault.
What did not work first
A few trial-and-error attempts were useful:
- Treating the
fraysterm as if it stayed inside the original 3-dimensional fiber span after multiplication byshuttlefailed. That produced inconsistent linear equations. - A direct bit-level elimination over the expanded system also failed for the same reason: the error lives in the transformed space, not the original span.
- The successful model was to recognize the post-shuttle object as a rank-bounded Gabidulin decoding problem.
That is the reason the first linearization attempt failed but the later decoding-based approach worked.
The decoding idea
The clean algebraic model is:
where f is the message linearized polynomial and e is a rank-bounded error term.
For Gabidulin codes, the standard way to decode is to build an error-span polynomial E and an interpolant N such that
and then recover f from that composition.
In this challenge, the effective error rank is 15 after the shuttle mixing, which still sits inside the unique decoding radius for the code parameters used here.
Why the final decoder works
The solver does four things:
- Reconstructs arithmetic in using the irreducible polynomial from the challenge.
- Solves the interpolation system for the composition polynomial.
- Recovers the message coefficients by descending recursion on the linearized polynomial.
- Inverts
knot, hashes the secret, and decrypts the AES-GCM ciphertext.
The final validation checks are important:
- The interpolation identity is verified before trying AES.
- The recovered secret is checked against
knotbefore deriving the key. - AES-GCM authentication confirms the derivation is correct.
Full solver
The working code is in solve.py. It is reproduced here for convenience:
import json
from hashlib import sha256
from Crypto.Cipher import AES
MOD_BITS = (1 << 21) | (1 << 3) | (1 << 1) | 1
MASK = (1 << 43) - 1
NUM_FIELD_BITS = 43
NUM_SECRET_WORDS = 8
NUM_COORDS = 40
ERROR_RANK = 15
def mul(a, b):
res = 0
aa = a
bb = b
while bb:
if bb & 1:
res ^= aa
bb >>= 1
aa <<= 1
if aa >> NUM_FIELD_BITS:
aa &= MASK
aa ^= MOD_BITS
return res & MASK
def sqr(a):
return mul(a, a)
def pow_field(a, e):
result = 1
base = a
while e:
if e & 1:
result = mul(result, base)
e >>= 1
if e:
base = sqr(base)
return result
def inv(a):
if a == 0:
raise ZeroDivisionError
return pow_field(a, (1 << NUM_FIELD_BITS) - 2)
def vec_mat_mul(v, m):
out = []
for j in range(len(m[0])):
acc = 0
for i, vi in enumerate(v):
acc ^= mul(vi, m[i][j])
out.append(acc)
return out
def mat_inv(m):
n = len(m)
aug = [row[:] + [0] * n for row in m]
for i in range(n):
aug[i][n + i] = 1
r = 0
for c in range(n):
pivot = None
for i in range(r, n):
if aug[i][c] != 0:
pivot = i
break
if pivot is None:
raise ValueError('singular matrix')
aug[r], aug[pivot] = aug[pivot], aug[r]
pivot_inv = inv(aug[r][c])
for j in range(2 * n):
aug[r][j] = mul(aug[r][j], pivot_inv)
for i in range(n):
if i != r and aug[i][c] != 0:
factor = aug[i][c]
for j in range(2 * n):
aug[i][j] ^= mul(factor, aug[r][j])
r += 1
return [row[n:] for row in aug]
def solve_linear_system_field(matrix, rhs):
n_vars = len(matrix[0])
pivots = {}
for row, rhs_value in zip(matrix, rhs):
vec = row[:]
value = rhs_value
while True:
col = None
for idx in range(n_vars - 1, -1, -1):
if vec[idx] != 0:
col = idx
break
if col is None:
if value != 0:
raise ValueError('inconsistent system')
break
if col in pivots:
prow, prhs = pivots[col]
factor = vec[col]
for idx in range(n_vars):
if prow[idx] != 0:
vec[idx] ^= mul(factor, prow[idx])
value ^= mul(factor, prhs)
else:
inv_piv = inv(vec[col])
for idx in range(n_vars):
if vec[idx] != 0:
vec[idx] = mul(vec[idx], inv_piv)
value = mul(value, inv_piv)
pivots[col] = (vec, value)
break
memo = {}
def solve(col):
if col in memo:
return memo[col]
if col not in pivots:
memo[col] = 0
return 0
row, value = pivots[col]
result = value
for idx in range(col):
if row[idx] != 0:
result ^= mul(row[idx], solve(idx))
memo[col] = result
return result
return [solve(i) for i in range(n_vars)]
def parse_vec(xs):
return [int(x) for x in xs]
def parse_mat(ms):
return [parse_vec(row) for row in ms]
def bits_to_field(bits):
value = 0
for i, bit in enumerate(bits):
if bit:
value |= 1 << i
return value
def eval_linearized(coeffs, x):
acc = 0
for i, coeff in enumerate(coeffs):
if coeff:
acc ^= mul(coeff, pow_field(x, 1 << i))
return acc
with open('output.json') as fh:
data = json.load(fh)
pegs = parse_vec(data['loom']['pegs'])
knot = parse_mat(data['loom']['knot'])
shuttle = parse_mat(data['loom']['shuttle'])
bolt = parse_vec(data['bolt'])
received = vec_mat_mul(bolt, shuttle)
matrix = []
rhs = []
for i in range(NUM_COORDS):
row = []
g_pows = [pow_field(pegs[i], 1 << j) for j in range(NUM_SECRET_WORDS + ERROR_RANK)]
r_pows = [pow_field(received[i], 1 << j) for j in range(ERROR_RANK)]
for j in range(NUM_SECRET_WORDS + ERROR_RANK):
row.append(g_pows[j])
for j in range(ERROR_RANK):
row.append(r_pows[j])
matrix.append(row)
rhs.append(pow_field(received[i], 1 << ERROR_RANK))
coeffs = solve_linear_system_field(matrix, rhs)
n_coeffs = coeffs[: NUM_SECRET_WORDS + ERROR_RANK]
e_coeffs = coeffs[NUM_SECRET_WORDS + ERROR_RANK :] + [1]
for i in range(NUM_COORDS):
left = eval_linearized(n_coeffs, pegs[i])
right = eval_linearized(e_coeffs, received[i])
if left != right:
raise ValueError('interpolation check failed')
f_coeffs = [0] * NUM_SECRET_WORDS
for m in range((NUM_SECRET_WORDS + ERROR_RANK - 1), ERROR_RANK - 1, -1):
accum = n_coeffs[m]
for a in range(ERROR_RANK):
j = m - a
if 0 <= j < NUM_SECRET_WORDS:
accum ^= mul(e_coeffs[a], pow_field(f_coeffs[j], 1 << a))
if m >= ERROR_RANK and m - ERROR_RANK < NUM_SECRET_WORDS:
f_coeffs[m - ERROR_RANK] = pow_field(accum, 1 << (NUM_FIELD_BITS - ERROR_RANK))
t = f_coeffs
knot_inv = mat_inv(knot)
secret = vec_mat_mul(t, knot_inv)
if vec_mat_mul(secret, knot) != t:
raise ValueError('knot inversion check failed')
secret_bytes = b''.join(int(v).to_bytes((NUM_FIELD_BITS + 7) // 8, 'big') for v in secret)
wrap_key = sha256(secret_bytes).digest()[:16]
iv = bytes.fromhex(data['vault']['iv'])
body = bytes.fromhex(data['vault']['body'])
tag = bytes.fromhex(data['vault']['tag'])
cipher = AES.new(wrap_key, AES.MODE_GCM, nonce=iv)
flag = cipher.decrypt_and_verify(body, tag)
print(flag.decode())
Flag meaning
The flag itself is written in leetspeak and mirrors the challenge theme: weaving, lambda-style algebra, bricks, and a trapdoor effect. Read normally, it points to the idea that the weave looks tangled, but once the right algebraic structure is recovered, the hidden trapdoor opens cleanly.
Summary
The solve is a rank-metric decoding problem disguised as matrix gibberish. The key was to stop treating the disturbance as a simple span issue and instead decode the Gabidulin-style structure after the shuttle transform. Once that was done, the secret, AES key, and flag all fell out deterministically.