$ cat writeup.md…
$ cat writeup.md…
hackthebox
Task: recover 16 stored patterns from a 6400×6400 Hopfield network weight matrix; each pattern is an 80×80 brain image with a flag character. Solution: eigendecomposition reveals 16 stored patterns; free Hopfield convergence from eigenvector and random initial states recovers all attractors; 6-bit binary keys in first 6 pixels order the characters into the flag.
You are provided with the network weights of a Hopfield Network with discrete bipolar units (i.e. each neuron can have a value -1 or 1). Can you obtain the patterns that the model has been trained with? Note: the patterns are encoded with indices 1-16.
English summary: Given a 6400×6400 weight matrix of a Hopfield network, recover the 16 stored bipolar patterns. Each pattern is an 80×80 image of a brain scan with a flag character embedded in the center. The first 6 pixels of each pattern encode a key (0–15) as 6-bit binary, which determines the character ordering.
Loading weights.npy reveals a 6400×6400 symmetric matrix with zero diagonal — the hallmark of a Hebbian-trained Hopfield network. Since 6400 = 80×80, each stored pattern is an 80×80 bipolar image.
The provided encode_pattern(key, arr) function shows how keys are embedded: the integer key is converted to 6-bit binary and written into the first 6 pixels of the flattened 80×80 array as +1 (bit=1) or -1 (bit=0).
Computing the eigenvalues of W with numpy.linalg.eigh reveals the structure:
This confirms exactly 16 patterns were stored in the network. In a Hopfield network trained with the Hebbian rule W = (1/N) Σ xᵢxᵢᵀ - I, the number of large positive eigenvalues equals the number of stored patterns.
The naive approach — constraining the first 6 bits to match keys 1–16 during Hopfield convergence — fails because the dominant attractor (corresponding to the largest eigenvalue) captures most constrained initializations. All 16 constrained runs converge to the same pattern.
The correct approach is to let the network converge freely from diverse initial states, then read the key from the converged pattern's first 6 pixels.
The top 16 eigenvectors of W point in directions aligned with the stored patterns. Using sign(eigenvector) as initial states for Hopfield convergence recovers 14 of the 16 unique attractors:
import numpy as np from numpy.linalg import eigh W = np.load('weights.npy') N = W.shape[0] # 6400 def hopfield_converge(W, state, max_iter=200): """Run synchronous Hopfield update until convergence.""" state = state.copy() for it in range(max_iter): new_state = np.sign(W @ state) new_state[new_state == 0] = 1 # break ties if np.array_equal(new_state, state): return state, it state = new_state return state, max_iter def get_key(pattern): """Read 6-bit key from first 6 pixels of pattern.""" first6 = pattern[:6] key_bits = ''.join(['1' if x == 1 else '0' for x in first6]) return int(key_bits, 2) # Eigendecomposition eigenvalues, eigenvectors = eigh(W) # Use sign of top 16 eigenvectors as initial states unique_patterns = {} for i in range(16): init = np.sign(eigenvectors[:, -(i+1)]) init[init == 0] = 1 pattern, iters = hopfield_converge(W, init) key = get_key(pattern) if key not in unique_patterns: unique_patterns[key] = pattern
The remaining 2 patterns are found by running Hopfield convergence from random bipolar states:
np.random.seed(42) for trial in range(500): init = np.random.choice([-1, 1], size=N) pattern, iters = hopfield_converge(W, init) key = get_key(pattern) if key not in unique_patterns: unique_patterns[key] = pattern if len(unique_patterns) >= 16: break
In a Hopfield network, if x is a stable attractor, then -x is also a stable attractor. The 16 "normal" patterns have keys 0–15, and their negatives have keys 48–63 (since negating the first 6 bits flips the 6-bit key). This provides a cross-check: we should find exactly 32 attractors total (16 pairs).
Each converged 80×80 pattern shows a brain scan image with a character rendered in the center region. Reading the characters in key order (0 through 15):
| Key | Binary | Character |
|---|---|---|
| 0 | 000000 | H |
| 1 | 000001 | T |
| 2 | 000010 | B |
| 3 | 000011 | { |
| 4 | 000100 | B |
| 5 | 000101 | r |
| 6 | 000110 | 4 |
| 7 | 000111 | 1 |
| 8 | 001000 | n |
| 9 | 001001 | _ |
| 10 | 001010 | d |
| 11 | 001011 | 4 |
| 12 | 001100 | n |
| 13 | 001101 | c |
| 14 | 001110 | 3 |
| 15 | 001111 | } |
Concatenated: HTB{Br41n_d4nc3} — "Brain dance" in leetspeak.
#!/usr/bin/env python3 """Spin Glass Brain — Hopfield network pattern recovery""" import numpy as np from numpy.linalg import eigh import matplotlib.pyplot as plt # Load weight matrix W = np.load('weights.npy') N = W.shape[0] # 6400 def hopfield_converge(W, state, max_iter=200): """Synchronous Hopfield update: state = sign(W @ state) until fixed point.""" state = state.copy() for it in range(max_iter): new_state = np.sign(W @ state) new_state[new_state == 0] = 1 if np.array_equal(new_state, state): return state, it state = new_state return state, max_iter def get_key(pattern): """Decode 6-bit key from first 6 pixels.""" first6 = pattern[:6] key_bits = ''.join(['1' if x == 1 else '0' for x in first6]) return int(key_bits, 2) # Phase 1: Eigendecomposition — find number of stored patterns eigenvalues, eigenvectors = eigh(W) n_patterns = np.sum(eigenvalues > 100) # 16 large positive eigenvalues print(f"Number of stored patterns: {n_patterns}") print(f"Top eigenvalues: {eigenvalues[-16:]}") # Phase 2: Recover attractors from eigenvector initializations unique_patterns = {} for i in range(16): init = np.sign(eigenvectors[:, -(i+1)]) init[init == 0] = 1 pattern, iters = hopfield_converge(W, init) key = get_key(pattern) if key not in unique_patterns: unique_patterns[key] = pattern print(f"Eigenvector {i}: key={key}, converged in {iters} iters") # Phase 3: Fill remaining patterns from random starts np.random.seed(42) for trial in range(500): init = np.random.choice([-1, 1], size=N) pattern, iters = hopfield_converge(W, init) key = get_key(pattern) if key not in unique_patterns: unique_patterns[key] = pattern print(f"Random trial {trial}: key={key}, converged in {iters} iters") if len(unique_patterns) >= 16: break print(f"\nFound {len(unique_patterns)} unique patterns with keys: {sorted(unique_patterns.keys())}") # Phase 4: Visualize and read flag fig, axs = plt.subplots(4, 4, figsize=(12, 12)) for idx, key in enumerate(range(16)): ax = axs[idx // 4, idx % 4] if key in unique_patterns: ax.imshow(unique_patterns[key].reshape(80, 80), cmap='gray') ax.set_title(f"Key {key}") ax.set_xticks([]) ax.set_yticks([]) plt.tight_layout() plt.savefig('recovered_patterns.png', dpi=150) plt.show() # The characters visible in each pattern spell out the flag # HTB{Br41n_d4nc3}
Constrained Hopfield convergence: Forcing the first 6 bits to match specific keys (1–16) during each update iteration caused all patterns to collapse to the dominant attractor (largest eigenvalue). This produced 16 copies of the same image regardless of the target key.
Procrustes rotation + Hopfield refinement: Attempted to find the correct rotation matrix in eigenspace via Procrustes alignment, then refine with Hopfield updates. More than 2 updates caused duplicates; fewer updates left residual noise.
Iterative thresholding in eigenspace: Alternated between binarization (sign function) and projection onto the top-16 eigenspace. Produced unique but noisy patterns that didn't cleanly converge to stored attractors.
$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar