Scenario
Deep beneath the Ash-Vault rests a relic the Cinderbound once swore could preserve truth without ever revealing it, a public rite built from equations, guarded only by the silent calculations its keepers left behind before they vanished. Unravel those old protections and the words sealed inside may finally be read, along with whatever authority the realm hoped they'd bury for good.
Code Analysis & Cryptosystem Breakdown
I started by opening up source.sage to see what I was actually dealing with. It turned out to be a custom public-key encryption scheme built on Multivariate Public Key Cryptography — the kind of construction that echoes Hidden Field Equations (HFE) — operating over the finite field F2=GF(2).
Key Generation (source.sage)
q = 2
def keygen(n):
K = GF(q)
_vars = [f"x{i}" for i in range(1, n+1)]
R = PolynomialRing(K, _vars, n)
J = ideal([x^q-x for x in R.gens()])
H = R.quotient_ring(J, _vars)
S_B = random_vector(K, n)
T_B = random_vector(K, n)
while True:
S_A, T_A = [matrix.random(K, n, n) for _ in range(2)]
if not False in [S_A.is_singular(), T_A.is_singular()]:
break
S = (S_A, S_B)
T = (T_A, T_B)
Rv = vector(R, n, R.gens())
Rv = S[0] * Rv + S[1]
PRK.<x> = PolynomialRing(K)
PRL.<t> = PolynomialRing(R)
g = PRK.irreducible_element(n)
g = PRL(g)
I = PRL.ideal([g])
Q = PRL.quotient_ring(I)
F = PRL(Rv.list()[::-1])
F = Q(F^(2*q)+F^q+1)
PK = vector(R, n, F.list()[::-1])
PK = T[0] * PK + T[1]
return PK
Breaking down what I was looking at:
- q=2, so every operation lives in F2.
- A linear transformation vector Rv=SA⋅x+SB, where each entry is an affine linear combination of the input variables x1,…,xN.
- A polynomial F(t)∈R[t]/(g(t)) built out of Rv.
- A transformation F↦F2q+Fq+1=F4+F2+1(mod)g(t).
- A final affine transformation PK=TA⋅coefficients(F)+TB.
What caught my eye immediately: SA and TA were deliberately generated as singular matrices. That felt like the kind of detail a challenge author leaves in on purpose.
Encryption & Flag Protection
encrypted_key = encrypt(KEY, PK)
AES_KEY = hashlib.sha256(str(KEY).encode()).digest()
cipher = AES.new(AES_KEY, AES.MODE_ECB)
enc_flag = cipher.encrypt(pad(FLAG, 16))
So a random 137-bit integer KEY gets generated, its bit array m∈{0,1}137 gets fed into PK(m), and the result — encrypted_key — is what I'd get in output.txt. Separately, SHA-256(str(KEY)) becomes the AES-ECB key used to encrypt the flag. If I could recover KEY, the flag would fall right out.
Mathematical Vulnerability & Collapse to Linear System
Characteristic 2 Properties & Bit Domain Constraints
This is where the singular matrices started making sense to me. In any field or ring of characteristic 2 (like F2), the Frobenius automorphism gives me:
Frobenius Automorphism:
(a+b)2=a2+b2(mod2)
(a+b)4=((a+b)2)2=a4+b4(mod2)
And because the input vector x=(x1,…,x137) only ever represents bit values xi∈{0,1}, I also get:
xi2=xi(mod2),xi4=xi(mod2)
That combination was the crack I needed.
Absence of Cross-Terms
Since Rv[i] is just an affine linear polynomial ∑j=1Ncjxj+c0, raising it to powers of 2 doesn't introduce anything new:
(Rv[i])2=∑j=1Ncjxj2+c0
(Rv[i])4=∑j=1Ncjxj4+c0
I noticed no cross-terms (like xixj for i=j) ever show up during this exponentiation — the Frobenius structure over F∗2 keeps everything additive. And since xi4 and xi2 both collapse back to xi on binary inputs, every public key polynomial component Pk(x1,…,x∗137) — despite looking like a degree-4 multivariate polynomial on paper — actually simplifies to a plain affine linear equation:
Pk(x)=∑j=1137Mk,jxj+ck(mod2)
where:
- Mk,j= (count of xj4 in Pk) + (count of xj2 in Pk) (mod2).
- ck=1 if the constant term
1 is present in Pk, else 0.
That meant the "hard" multivariate public key evaluation was really just:
encrypted∗*key∗=M⋅∗x∗+c(mod2)⟹M⋅∗x∗=*encryptedkey⊕c(mod2)
A linear system in disguise. Once I saw that, I knew this was going to come down to straightforward linear algebra over F2.
Resolving the Singular Matrix & Linear System
Here's how I worked through the rest of it:
- System Construction: I built the 137×137 matrix M and vector b=encryptedkey⊕c over F2, parsing the public key polynomials directly out of
output.txt.
- Matrix Rank: Running Gaussian elimination to Reduced Row Echelon Form (RREF), I found rank(M)=135 — not full rank. That rank deficiency was exactly what I expected, since TA and SA had been chosen singular from the start.
- Kernel & Enumeration: A rank of 135 over a 137-dimensional space leaves a 2-dimensional nullspace, which meant only 22=4 candidate solutions to check.
- Candidate Check: I enumerated all 4 candidates and checked each against the AES-encrypted flag until I found the unique 137-bit string x corresponding to
KEY.
Complete Solver Script
import ast
import hashlib
import itertools
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad
def solve():
with open('crypto_ashen_field/output.txt') as f:
lines = [line.strip() for line in f if line.strip()]
pk_str = lines[0][1:-1]
polys = pk_str.split(', ')
enc_key = ast.literal_eval(lines[1])
enc_flag = bytes.fromhex(lines[2])
N = 137
M = [[0]*N for _ in range(N)]
c = [0]*N
for k, p in enumerate(polys):
terms = p.split(' + ')
for t in terms:
if t == '1':
c[k] ^= 1
else:
var = t.split('^')[0]
idx = int(var[1:]) - 1
M[k][idx] ^= 1
b = [enc_key[k] ^ c[k] for k in range(N)]
# Gaussian elimination to RREF over GF(2)
aug = [M[i] + [b[i]] for i in range(N)]
pivot_cols = []
pivot_row_map = {}
row = 0
for col in range(N):
pivot = None
for r in range(row, N):
if aug[r][col] == 1:
pivot = r
break
if pivot is None:
continue
aug[row], aug[pivot] = aug[pivot], aug[row]
pivot_cols.append(col)
pivot_row_map[col] = row
for r in range(N):
if r != row and aug[r][col] == 1:
for c_idx in range(col, N + 1):
aug[r][c_idx] ^= aug[row][c_idx]
row += 1
free_cols = [col for col in range(N) if col not in pivot_row_map]
# Test all 2^len(free_cols) candidates
for free_vals in itertools.product([0, 1], repeat=len(free_cols)):
x = [0]*N
for col, val in zip(free_cols, free_vals):
x[col] = val
for col in pivot_cols:
r = pivot_row_map[col]
val = aug[r][N]
for f in free_cols:
val ^= (aug[r][f] & x[f])
x[col] = val
KEY = sum(x[i] * (1 << i) for i in range(N))
if KEY.bit_length() != N:
continue
AES_KEY = hashlib.sha256(str(KEY).encode()).digest()
cipher = AES.new(AES_KEY, AES.MODE_ECB)
try:
flag = unpad(cipher.decrypt(enc_flag), 16)
print(f"[+] Flag found: {flag.decode()}")
return flag.decode()
except Exception:
continue
if __name__ == '__main__':
solve()
Execution
Running my solver gave me exactly what I was after:
[+] Flag found: HTB{{REDACTED}}
What looked like an intimidating quartic multivariate cryptosystem turned out to be nothing more than linear algebra wearing a costume — the deliberately singular matrices were the tell the whole way through.