CryptoHack The Boo·

Hack The Boo CTF 2025 | Crypto

All Crypto challenges from HackTheBoo 2025

med HackTheBox HackTheBoo 2025 Crypto RSA
8 min reading · edit 2025-10-27

Hack The Boo CTF 2025 was a 2-day CTF hosted by HackTheBox. I competed solo and finished in the top 5 out of 2,893 players. This post covers the Crypto challenges.

Sign and Run [medium]

At the edge of Hollow Mere stands an ancient automaton known as the Iron Scribe - a machine that writes commands in living metal and executes only those sealed with a valid mark. The Scribe’s master key was lost ages ago, but its forges still hum, stamping glyphs of permission into every order it receives. Willem approaches the machine’s console, where it offers a bargain: “Sign your words, and I shall act. Present a forged seal, and be undone.” To awaken the Scribe’s obedience, one must understand how its mark is made… and how to make it lie.

Step 1 – Understanding the Scribe

Connecting with nc 46.101.173.67 32368 shows a banner with a 2048-bit RSA modulus every session. Reading server.py:

1pt = bytes_to_long(cmd)
2ct = pow(pt, d, N)
3sig = crc32(long_to_bytes(ct))
4print(f"Encrypted signature: {pow(sig, e, N)}")

During invoke, the service recomputes that same 32-bit sig and compares it with the user-supplied seal. Nothing more.. no PKCS#1 padding, no blind signing, just an RSA-encrypted CRC32. That means the only “secret” is a 32-bit integer; once you invert pow(sig, e, N) you can run arbitrary commands.

Step 2 – Early Attempts

I tried several angles before brute-forcing:

  • Attempted to send raw binary/UTF-8 commands hoping to influence bytes_to_long; the server happily accepted anything but always hashed the RSA “decryption” with CRC32.
  • Dug through older RSA write-ups (blind signatures, PKCS#1 laxness, multiplicative tricks). All require the service to issue signatures on chosen messages. Here, you only ever get RSA applied to the CRC, not the command itself, so there’s nothing algebraic to exploit.

So despite the lore hinting at “forging a mark,” the only viable solution I found is to brute-force the 32-bit CRC directly.

Step 3 – High-Throughput Brute Force

I wrote the brute-forcer in C using GMP:

1mpz_powm_ui(result, base, 65537, N);
2if (mpz_cmp(result, target) == 0) { ... }

Each thread walks a dedicated slice of the 0..2^32 range, and a shared flag stops the pool once any thread finds the match. Compiled with gcc brute_gmp.c -O3 -march=native -lgmp -lpthread, this reaches ~1.5M powmods/sec per core on my machine.

exploit.py keeps the socket alive while brute_gmp runs. As soon as a candidate seal is found, it sends invoke cat</flag.txt <sig> over that same connection and prints the flag.

I won’t upload the code here, as I believe there is a quicker soltuion, but for that you will have to look for the official writeup from HTB

Step 4 – Results

After far too many retries, and eventually renting a beefier VPS because my own machine would have taken roughly 4× longer, the brute-force finally succeeded:

1[+] Found signature: 4161569746
2[+] Time taken: 3772.00 seconds (62.87 minutes)
1HTB{w3_sh0u1d_m3333333t_1n_th3_m1dd13!!!!!_cc66053315777e798944cdbf28cf9836}

Closing Thoughts

Despite the flavor text hinting at an elegant “forgery”, the practical exploit is a high-speed search over 2^32 RSA encryptions. It works, but it feels more like a hardware contest. I’m still waiting for the official HTB write-up to see whether there’s a smarter way.

Leaking for Answers [easy]

Willem’s path led him to a lone reed-keeper on the marsh bank. The keeper speaks only in riddles and will reveal nothing for free - yet he offers tests, one for each secret he guards. Each riddle is a vetting: answer each in turn, and the keeper will whisper what the fen keeps hidden. Fail, or linger too long answering the questions, and the marsh swallows the night. This is a sourceless riddle stand - connect, answer each of the keeper’s four puzzles in sequence, and the final secret will be yours.

Step 1: Talk to the Keeper

First, try to connect.

1nc 68.183.73.240 31521

Output snippet:

1The keeper croaks once and waits...
2n = 2386...
3p-q = 2684...
4[1] Speak the pair of primes as (p,q) :

The service expects prime factors of n. Rerunning would cycle through four distinct challenge types, we need an automated solver.

Step 2: Catalogue the Leak Types

Interacting manually revealed four RSA variants:

  1. Known difference: p−q = diff. Standard quadratic solution: root = sqrt(diff^2 + 4n), p = (diff + root)/2, q = p − diff.

  2. φ inverse leak: The prompt showed pow(phi, -1, n) * d % n = leak along with e. Rearranged from ed − kφ = 1, we brute-forced small k to recover φ, then solved for p and q if the reconstruction was consistent.

  3. Private exponent leak: k = ed − 1 factorisation with repeated attempts of the Boneh–Durfee style loop (random g, square repeatedly, check gcd when squaring hits 1) until a non-trivial factor.

  4. Inverse pair leak: The keeper leaked A = pow(p, -q, q) and B = pow(q, -p, p). From A·p ≡ 1 (mod q) and B·q ≡ 1 (mod p) we arrive at A·p + B·q ≡ 1 (mod n) and in practice A·p + B·q = n + 1. Substituting into a quadratic yields the discriminant Δ = (n + 1)^2 − 4ABn, so

    1root = isqrt(Δ)
    2p = ((n + 1) ± root) / (2A)
    3q = n / p

Each scenario needs its own function and a safety net (Fermat, Pollard’s rho, small trial division).

Step 3: Building the script

You could usegmpy2, but I used python’s math.isqrt.

  1import random
  2import re
  3import socket
  4from math import gcd, isqrt
  5
  6HOST, PORT = "68.183.73.240", 31521
  7
  8inv = lambda a, m: pow(a, -1, m)
  9
 10def pq(n, diff):
 11    r = isqrt(diff * diff + 4 * n)
 12    p = (diff + r) // 2
 13    return p, n // p
 14
 15def phi_leak(n, e, leak):
 16    for k in range(1, 200000):
 17        x = (e * leak - k) % n
 18        if x and gcd(x, n) == 1:
 19            phi = inv(x, n)
 20            s = n - phi + 1
 21            d = s * s - 4 * n
 22            r = isqrt(d)
 23            if r * r == d:
 24                p = (s + r) // 2
 25                return p, n // p
 26
 27def d_leak(n, e, d):
 28    k = e * d - 1
 29    s = 0
 30    while k % 2 == 0:
 31        k //= 2
 32        s += 1
 33    for _ in range(64):
 34        g = random.randrange(2, n - 1)
 35        x = pow(g, k, n)
 36        if x in (1, n - 1):
 37            continue
 38        for _ in range(s):
 39            y = pow(x, 2, n)
 40            if y == 1:
 41                p = gcd(x - 1, n)
 42                return p, n // p
 43            if y == n - 1:
 44                break
 45            x = y
 46
 47def inv_leak(n, a, b):
 48    d = (n + 1) * (n + 1) - 4 * a * b * n
 49    r = isqrt(d)
 50    for sign in (1, -1):
 51        num = (n + 1) + sign * r
 52        den = 2 * a
 53        if den and num % den == 0:
 54            p = num // den
 55            if p > 1:
 56                return p, n // p
 57
 58def fermat(n, iters=200000):
 59    a = isqrt(n)
 60    if a * a < n:
 61        a += 1
 62    for _ in range(iters):
 63        b2 = a * a - n
 64        b = isqrt(b2)
 65        if b * b == b2:
 66            return a + b, a - b
 67        a += 1
 68
 69def read_prompt(sock):
 70    buf = b""
 71    while True:
 72        chunk = sock.recv(4096)
 73        if not chunk:
 74            break
 75        buf += chunk
 76        low = buf.lower()
 77        if b"htb{" in buf:
 78            break
 79        if b"speak" in low or b"(p,q" in low:
 80            break
 81    return buf.decode()
 82
 83
 84def solve(text):
 85    n = int(re.search(r"n\s*=\s*(\d+)", text).group(1))
 86    if "p-q" in text:
 87        diff = int(re.search(r"p-q\s*=\s*(-?\d+)", text).group(1))
 88        return pq(n, diff)
 89    if "pow(phi" in text:
 90        e = int(re.search(r"e\s*=\s*(\d+)", text).group(1))
 91        leak = int(re.search(r"pow\(phi.*?=\s*(\d+)", text).group(1))
 92        return phi_leak(n, e, leak)
 93    if "d =" in text:
 94        e = int(re.search(r"e\s*=\s*(\d+)", text).group(1))
 95        d = int(re.search(r"d\s*=\s*(\d+)", text).group(1))
 96        return d_leak(n, e, d)
 97    if "pow(p, -q" in text:
 98        a = int(re.search(r"pow\(p, -q, q\)\s*=\s*(\d+)", text).group(1))
 99        b = int(re.search(r"pow\(q, -p, p\)\s*=\s*(\d+)", text).group(1))
100        return inv_leak(n, a, b)
101    return fermat(n)
102
103with socket.create_connection((HOST, PORT)) as s:
104    for _ in range(8):
105        data = read_prompt(s)
106        print(data)
107        if "HTB{" in data:
108            break
109        p, q = solve(data)
110        s.sendall(f"{p},{q}\n".encode())

The main loop opened the socket, read until the Speak the pair of primes prompt, dispatched to the right solver, sanity-checked p*q == n, and sent the answer. Extra guards retried or fell back to 2,2 if something unexpected happened.

Step 4: Flag

Running the script solved all four rounds back-to-back and triggered the finale.

1python3 exploit.py

Output snippet:

1pow(q, -p, p) = 42852827395689524086438949641477969773433092939989784749451440029892365430882527966362537470683299500634711589188279805815655104612810439303734046609232546763988778418954454646538252634253701418351175018232172091283445825517370993661469015333257282066358762311684136680072293208143191687024832878888343744051
2[4] Speak the pair of primes as (p,q) : 
3The keeper bows. You have answered all his tests.
4HTB{t0_l34k___0r_n0t___t0_l34k_f0r_4nsw3rs_0af9ea5217203e9f59e7a04b75191755}