HTB: Always Has Been Challenge
Always Has Been - HackTheBox Challenge Writeup
Challenge Information
| Field | Value |
|---|---|
| Name | Always Has Been |
| Category | Misc / Crypto |
| Difficulty | Hard |
| Author | d3vn0mi |
Description
To be honest, rolling your own crypto has always been pretty easy.
The challenge ships a custom, “hardened” hashing scheme (securehash.py) built entirely from scratch — a homebrew S-box, a fixed bit permutation, and a hundred rounds of key mixing, plus a comment bragging that “No linear or differential cryptanalysis here!” The provided artifact is a single 64-character hex digest, and the goal is to recover the original 32-byte input (the flag) that produces it.
Solution
Artifact recovery
The extracted challenge directory only contained a 0-byte output.txt, which looked broken at first glance. Listing the contents of the distributed zip (unzip -l) showed the real files inside had non-zero sizes — meaning the archive was password-protected and staging extraction had silently failed. Re-extracting with the standard HackTheBox archive password recovered the real files:
unzip -o -P hackthebox challenge.zipThis produced two files:
output.txt— a 64-character hex digest (the hash output)securehash.py— the homebrew hash implementation
Cracking the “custom crypto”
securehash.py computes the digest as:
# Simplified reconstruction of the challenge's hashing schemestate = bytes(32) # 0^32state = xor(state, encrypt(flag, key=flag)) # state = 0^32 XOR E_flag(flag)digest = stateBecause the flag is exactly 32 bytes — one full block — the key and the plaintext being encrypted are the same value. That means the digest is really just E_k(k): the cipher encrypting its own key.
Each of the 100 rounds inside encrypt() does three things:
# One round of the custom cipherfor i in range(32): sbox[i] = KEY_SBOX[i] ^ key[0] # "S-box" — really just XOR with key[0]state = permute(state, FIXED_PERMUTATION) # a fixed 256-bit bit permutationstate = xor(state, key) # key mixingThe comment in the source claims immunity to linear and differential cryptanalysis — but the “S-box” isn’t a real S-box at all. It’s a lookup table XORed with a single key byte:
# Verifying the S-box is affine (GF(2)-linear) by brute forcefor i in range(65536): a = i >> 8 b = i & 0xFF assert (KEY_SBOX[a] ^ KEY_SBOX[0]) ^ (KEY_SBOX[b] ^ KEY_SBOX[0]) == \ KEY_SBOX[a ^ b] ^ KEY_SBOX[0]# Holds for all 65536 pairs -> S-box construction is GF(2)-linear (affine)Since KEY_SBOX[i] ^ KEY_SBOX[0] is linear over GF(2) for every input, the “S-box” step is nothing but an affine map. Combined with the fixed bit permutation (also linear) and the XOR key mixing (affine), the entire 100-round cipher is affine over GF(2) — the exact opposite of what the source comment claims.
Building the linear model
Because the linear component T = P ∘ Lin_S (permutation composed with the S-box’s linear part) doesn’t depend on the key at all — the key only shifts the S-box by a constant — the whole cipher collapses to a closed-form affine equation:
hash = (T^100 + M)(k) XOR M(P(c_vec))
where: M = T^0 + T^1 + T^2 + ... + T^99 (sum of all round-linear-maps, over GF(2)) c_vec = (170 XOR key[0]) repeated 32 timesT^100 and M are both 256×256 GF(2) matrices that can be precomputed once (they’re entirely key-independent). The only remaining unknown is a single byte, key[0], which appears both as the affine offset and inside c_vec.
Solving the system
# For each of the 256 possible values of key[0]:# 1. Build c_vec = (170 ^ guess) * 32# 2. Form the linear system (T^100 + M)(k) = digest XOR M(P(c_vec))# 3. Solve over GF(2) via Gaussian elimination# 4. Enumerate the nullspace for all solutions (system may be underdetermined)# 5. Keep only solutions whose k[0] byte matches the guessed value
candidates = []for guess in range(256): c_vec = bytes([170 ^ guess] * 32) rhs = xor(digest, apply(M, permute(c_vec))) A = matrix_sum(T_pow(100), M) # (T^100 + M), 256x256 over GF(2) for k in solve_gf2_nullspace(A, rhs): if k[0] == guess: candidates.append(k)This produced two surviving candidates for the 32-byte key (i.e. the flag). Re-encrypting each candidate through the original securehash.py and comparing against the target digest identified the correct one and confirmed it decodes to a valid, readable flag.
Key Steps
- Recovered the password-protected challenge archive with
unzip -P hackthebox. - Read
securehash.pyand identifiedstate = 0^32 XOR E_flag(flag)— a self-encrypting single-block hash. - Brute-forced all 65,536 S-box input pairs to prove
KEY_SBOX[i] ^ KEY_SBOX[0]is GF(2)-linear, exposing the “S-box” as a disguised affine map. - Derived a closed-form affine expression for the 100-round cipher:
hash = (T^100 + M)(k) XOR M(P(c_vec)), withT/Mprecomputable and key-independent. - Brute-forced the single unknown byte
key[0](256 guesses), solving a 256×256 GF(2) linear system and enumerating its nullspace for each guess. - Filtered candidates by self-consistency (
k[0] == guess), yielding two surviving 32-byte candidates. - Verified by re-running the original hash function on each candidate and matching against the target digest.
Tools Used
- Python (custom GF(2) linear algebra: Gaussian elimination, nullspace enumeration, matrix exponentiation)
unzip(password-protected archive recovery)- Manual cryptanalysis of a homebrew cipher (linearity testing, affine-cipher reduction)
Key Learnings
- A cipher’s individual components can each look nonlinear at a glance (a lookup table, a permutation, a keyed XOR) while still composing into something fully affine over GF(2) — always test claimed nonlinear primitives (like S-boxes) for linearity before trusting them.
- Hashing by “encrypt the input with itself as the key” (
E_flag(flag)) collapses a stream/block cipher into a known-plaintext-equals-key scenario, which is a serious design smell — recovering the key becomes equivalent to recovering the plaintext. - Once a cipher is proven affine, the whole system reduces to solving a linear system over GF(2); any small residual keyspace (here, a single byte) can just be brute-forced around the linear solve.
- “Rolling your own crypto” bugs often hide in claims made directly in the source comments — the challenge’s own assertion of immunity to linear cryptanalysis was the exact property that broke it.
- Corrupted or 0-byte artifacts from challenge staging are often just password-protected zips extracted incorrectly — always check
unzip -lfor real file sizes before assuming data loss.