Neurogrid CTF: Human-Only 2025 | Crypto
Crypto challenges from Neurogrid CTF: Human-Only 2025
Neurogrid CTF: Human-Only 2025 was a 4-day CTF hosted by HackTheBox. I competed solo and finished in the top 4 out of 1,337 players. This post covers the Crypto challenges.


Coordinator [hard]
Description: A mystical coordinator manages random assignments. Can you predict its next move?
Points: 1000 | Difficulty: Hard
Analysis
A Flask web application with two endpoints that expose outputs from Python’s random module. The challenge involves recovering the internal state of the Mersenne Twister PRNG (MT19937).
The application generates quadratic equations where the coefficients are derived from random.getrandbits(). Given two roots of each quadratic, we need to recover the original coefficients, which requires solving a system involving the PRNG outputs.
The key insight is that from the roots r1 and r2 of a quadratic ax² + bx + c = 0, we know:
- r1 + r2 = -b/a (sum of roots)
- r1 * r2 = c/a (product of roots)
But the coefficients are masked by random values, so we need LLL lattice reduction to recover the actual PRNG outputs from these relationships.
Exploitation
The attack proceeds in stages:
- Collect equations: Query the endpoint to get quadratic equation roots
- Lattice construction: Build a lattice where short vectors correspond to valid PRNG outputs
- LLL reduction: Apply the LLL algorithm to find short vectors
- State recovery: Once we have 624 consecutive 32-bit outputs, use
randcrackto clone the MT19937 state - Flag decryption: Predict future random values to XOR-decrypt the flag
1from sage.all import *
2import randcrack
3
4# After collecting 624 values via LLL recovery
5rc = randcrack.RandCrack()
6for val in recovered_values:
7 rc.submit(val)
8
9# Predict the key used for flag encryption
10key = rc.predict_getrandbits(256)
11flag = xor(encrypted_flag, key)The LLL lattice is constructed to find integer solutions that satisfy the quadratic root relationships while keeping values within the expected 32-bit range of PRNG outputs.
Flag
Flag:
HTB{r4nd0m_m0dul3_f0r_th3_r3scu3___c0mb1n3d_w1th_Lx3!}
Stones [medium]
Description: In the meditation halls of Saihō Temple, Rei discovers a chamber filled with sealed stones-each etched with twin markings that hum in rhythm. The monks say they were once used for communion between distant minds, a thousand messages waiting for the right voice to awaken them. Yet only one stone sings back when touched, and its song feels hauntingly familiar.
Points: 975 | Difficulty: Medium
Analysis
The challenge provides 2^20 “stones” - AES-ECB encrypted blocks with 24-bit keys. We’re also given oracle.txt containing a known plaintext sigil_a. The goal is to find the stone encrypted with a specific key to recover sigil_b, which decrypts the flag.
The vulnerability is the weak key space: only 2^24 possible AES keys (24 bits = 3 bytes). This is trivially brute-forceable.
From the challenge files:
- Each stone is encrypted with a different 24-bit key (padded to 128 bits)
sigil_ais the known plaintext for one of the stones- Finding the matching key reveals
sigil_b
Exploitation
Build a lookup table of first blocks for all possible keys, then find the match:
1from Crypto.Cipher import AES
2import os
3
4known_plaintext = b'sigil_a\x00\x00\x00\x00\x00\x00\x00\x00\x00' # padded to 16 bytes
5
6# Build lookup table: first_block -> key
7lookup = {}
8for key_int in range(2**24):
9 key = key_int.to_bytes(3, 'big').ljust(16, b'\x00')
10 cipher = AES.new(key, AES.MODE_ECB)
11 encrypted = cipher.encrypt(known_plaintext)
12 lookup[encrypted] = key_int
13
14# Load stones and find match
15for stone_file in stones:
16 first_block = open(stone_file, 'rb').read(16)
17 if first_block in lookup:
18 key_int = lookup[first_block]
19 # Decrypt to get sigil_b
20 key = key_int.to_bytes(3, 'big').ljust(16, b'\x00')
21 cipher = AES.new(key, AES.MODE_ECB)
22 sigil_b = cipher.decrypt(stone_data)
23 break
24
25# Use sigil_b to decrypt the flagThe 2^24 key space takes only seconds to brute force on modern hardware. The flag message hints at the vulnerability: Python’s random module (used to generate keys) is not cryptographically secure.
Flag
Flag:
HTB{pyth0n_r4nd0m_1s_n0t_crypt0_s4f3}
Dathash Or Not Dathash [easy]
Description: Deeper within the temple’s vaults, Rei finds three mirrored tablets inscribed with divine equations—each claiming to reflect the same truth. Yet the air around them warps with faint echoes, as if reality itself trembles at their alignment. The monks once used these relics to test the nature of authenticity, but none remember which mirror held the real reflection.
Points: 975 | Difficulty: Easy
Analysis
Classic RSA broadcast attack scenario with a twist. Three RSA public keys with small exponent e=3 encrypt the same message with linear padding:
1c1 = (m + k1 * 2^2025)^3 mod n1
2c2 = (m + k2 * 2^2025)^3 mod n2
3c3 = (m + k3 * 2^2025)^3 mod n3The exponent e=3 is forced by the isPrime(e) and isPrime(e-1) constraints - only 3 satisfies both conditions (3 is prime, 2 is prime).
With three ciphertexts of the same message under different moduli, we can use:
- Chinese Remainder Theorem (CRT): Combine the three congruences
- Håstad’s Broadcast Attack: When e ciphertexts of the same message exist under e different moduli, we can recover m
- Coppersmith’s method: The linear padding creates a polynomial with small roots - use
small_roots()to recover m
Exploitation
1from sage.all import *
2
3# Given: n1, n2, n3, c1, c2, c3, k1, k2, k3
4# e = 3, padding offset = 2^2025
5
6# Use CRT to combine moduli
7N = n1 * n2 * n3
8
9# Create polynomial ring over Z/NZ
10P.<x> = PolynomialRing(Zmod(N))
11
12# Construct the combined polynomial using CRT coefficients
13# Each (x + ki * 2^2025)^3 - ci ≡ 0 mod ni
14
15# Apply Coppersmith's small_roots()
16# Message m is ~1500 bits, which is < N^(1/3) for 2048-bit moduli
17f = ... # combined polynomial
18roots = f.small_roots(X=2^1500, beta=0.5)
19
20m = int(roots[0])
21flag = bytes.fromhex(hex(m)[2:])The message being 1500 bits fits within the N^(1/3) bound for Coppersmith’s method to work, making this a textbook application of the combined Håstad + Coppersmith attack.
Flag
Flag:
HTB{h0w_t0_c0mb1n3_h4574d_b04rdc457_4nd_c0pp3rsm17h_4774ck}
Elliptic Contribution [very easy]
Description: In the moonlit archives of Saihō Temple, Rei uncovers a scroll that hums softly when touched, its ink swirling like a living current. The monks call it the Ledger of Offerings-a relic that accepts numbers as prayers and returns sealed verses in response. Each inscription deepens the scroll’s rhythm, as if something beneath the ink is learning to listen.
Points: 975 | Difficulty: Very Easy
Analysis
An ECC challenge where the prime p is hidden but recoverable, and the curve order has a small factor enabling a subgroup attack.
The setup:
- Prime p is unknown but can be recovered via GCD from point operations
- User controls the base point G through an “offering” mechanism
- The server computes k*G for a secret scalar k
- Curve order contains small factor: 1333357
The vulnerability is a small subgroup attack. If we can confine the secret key k to a small subgroup, we only need to brute force ~1.3M possibilities instead of the full key space.
Exploitation
- Recover p: Submit points and use GCD on the differences to recover the hidden prime
- Find curve order: Factor the curve order to identify small factors
- Choose subgroup generator: Pick a point G’ of order 1333357
- Submit offering: Set the base point to G'
- Receive k*G’: Since G’ has small order, k*G’ = (k mod 1333357)*G'
- Brute force: Check all 1333357 possible values of (k mod 1333357)
1from sage.all import *
2
3# After recovering p and curve parameters
4E = EllipticCurve(GF(p), [a, b])
5order = E.order()
6
7# Factor to find small subgroup
8# order has factor 1333357
9
10# Find generator of small subgroup
11small_order = 1333357
12cofactor = order // small_order
13G_small = cofactor * E.random_point() # Point of order small_order
14
15# Submit G_small as offering, receive Q = k * G_small
16Q = ... # from server
17
18# Brute force: find i such that i * G_small == Q
19for i in range(small_order):
20 if i * G_small == Q:
21 k_mod = i
22 break
23
24# k_mod reveals enough information to decrypt the flagThe flag message confirms the vulnerability: small order causes repeated outputs, making the discrete log tractable.
Flag
Flag:
HTB{___sm4ll_0rd3r_c4us3s_r3p34t3d_0utputs____29dc6187db868e10ca9cd8f7d11cd2dd}