Contents

Neurogrid CTF: Human-Only 2025 | Crypto

Neurogrid CTF: Human-Only 2025 was a 4-day CTF hosted by HackTheBox. I competed solo as “Legendary Queue” and finished in the top 4 out of 1337 players. This post covers the Crypto challenges.

Neurogrid CTF: Human-Only 2025

Team Solves Progress


Description: A mystical coordinator manages random assignments. Can you predict its next move?

Points: 1000 | Difficulty: Hard

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.

The attack proceeds in stages:

  1. Collect equations: Query the endpoint to get quadratic equation roots
  2. Lattice construction: Build a lattice where short vectors correspond to valid PRNG outputs
  3. LLL reduction: Apply the LLL algorithm to find short vectors
  4. State recovery: Once we have 624 consecutive 32-bit outputs, use randcrack to clone the MT19937 state
  5. Flag decryption: Predict future random values to XOR-decrypt the flag
from sage.all import *
import randcrack

# After collecting 624 values via LLL recovery
rc = randcrack.RandCrack()
for val in recovered_values:
    rc.submit(val)

# Predict the key used for flag encryption
key = rc.predict_getrandbits(256)
flag = 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: HTB{r4nd0m_m0dul3_f0r_th3_r3scu3___c0mb1n3d_w1th_Lx3!}


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

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_a is the known plaintext for one of the stones
  • Finding the matching key reveals sigil_b

Build a lookup table of first blocks for all possible keys, then find the match:

from Crypto.Cipher import AES
import os

known_plaintext = b'sigil_a\x00\x00\x00\x00\x00\x00\x00\x00\x00'  # padded to 16 bytes

# Build lookup table: first_block -> key
lookup = {}
for key_int in range(2**24):
    key = key_int.to_bytes(3, 'big').ljust(16, b'\x00')
    cipher = AES.new(key, AES.MODE_ECB)
    encrypted = cipher.encrypt(known_plaintext)
    lookup[encrypted] = key_int

# Load stones and find match
for stone_file in stones:
    first_block = open(stone_file, 'rb').read(16)
    if first_block in lookup:
        key_int = lookup[first_block]
        # Decrypt to get sigil_b
        key = key_int.to_bytes(3, 'big').ljust(16, b'\x00')
        cipher = AES.new(key, AES.MODE_ECB)
        sigil_b = cipher.decrypt(stone_data)
        break

# Use sigil_b to decrypt the flag

The 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: HTB{pyth0n_r4nd0m_1s_n0t_crypt0_s4f3}


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

Classic RSA broadcast attack scenario with a twist. Three RSA public keys with small exponent e=3 encrypt the same message with linear padding:

c1 = (m + k1 * 2^2025)^3 mod n1
c2 = (m + k2 * 2^2025)^3 mod n2
c3 = (m + k3 * 2^2025)^3 mod n3

The 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:

  1. Chinese Remainder Theorem (CRT): Combine the three congruences
  2. Håstad’s Broadcast Attack: When e ciphertexts of the same message exist under e different moduli, we can recover m
  3. Coppersmith’s method: The linear padding creates a polynomial with small roots - use small_roots() to recover m
from sage.all import *

# Given: n1, n2, n3, c1, c2, c3, k1, k2, k3
# e = 3, padding offset = 2^2025

# Use CRT to combine moduli
N = n1 * n2 * n3

# Create polynomial ring over Z/NZ
P.<x> = PolynomialRing(Zmod(N))

# Construct the combined polynomial using CRT coefficients
# Each (x + ki * 2^2025)^3 - ci ≡ 0 mod ni

# Apply Coppersmith's small_roots()
# Message m is ~1500 bits, which is < N^(1/3) for 2048-bit moduli
f = ... # combined polynomial
roots = f.small_roots(X=2^1500, beta=0.5)

m = int(roots[0])
flag = 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: HTB{h0w_t0_c0mb1n3_h4574d_b04rdc457_4nd_c0pp3rsm17h_4774ck}


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

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.

  1. Recover p: Submit points and use GCD on the differences to recover the hidden prime
  2. Find curve order: Factor the curve order to identify small factors
  3. Choose subgroup generator: Pick a point G’ of order 1333357
  4. Submit offering: Set the base point to G'
  5. Receive k*G’: Since G’ has small order, k*G’ = (k mod 1333357)*G'
  6. Brute force: Check all 1333357 possible values of (k mod 1333357)
from sage.all import *

# After recovering p and curve parameters
E = EllipticCurve(GF(p), [a, b])
order = E.order()

# Factor to find small subgroup
# order has factor 1333357

# Find generator of small subgroup
small_order = 1333357
cofactor = order // small_order
G_small = cofactor * E.random_point()  # Point of order small_order

# Submit G_small as offering, receive Q = k * G_small
Q = ... # from server

# Brute force: find i such that i * G_small == Q
for i in range(small_order):
    if i * G_small == Q:
        k_mod = i
        break

# k_mod reveals enough information to decrypt the flag

The flag message confirms the vulnerability: small order causes repeated outputs, making the discrete log tractable.

Flag: HTB{___sm4ll_0rd3r_c4us3s_r3p34t3d_0utputs____29dc6187db868e10ca9cd8f7d11cd2dd}

Related Content