cryptofreehard

Infosekurus Query

HackTheBox

Task: Custom auth system with RSA-encrypted passcode (13 key pairs), AES-ECB Merkle-Damgard hash oracle, and 2FA token verification. Solution: Leak phi via non-coprime exponent e=8192, recover passcode with successive square roots + CRT, exploit operator precedence bug in rxor to get true hash from oracle, then Merkle-Damgard length extension + offline brute-force of 12^5 candidates to forge 2FA token.

$ ls tags/ techniques/
rsa_phi_leak_non_coprime_exponentsuccessive_modular_square_roots_crtmerkle_damgard_length_extensionoffline_hash_brute_forceoperator_precedence_exploitation

Infosekurus Query — HackTheBox

Description

"I warned my boss, Mr. Dam, that guarding his sensitive data with a custom authentication system wasn't secure at all. He laughed and bet me that if I could somehow bypass it, he would give everyone in the office a promotion. Upon hearing this, the entire office UNIted is rooting for me..."

The server implements a custom authentication system with three options:

  1. Encrypted Passcode — RSA encryption of a secret passcode using one of 13 key pairs with different exponents
  2. Hash Oracle — Custom Merkle-Damgard hash (AES-ECB based) with a 27-byte input limit
  3. Authenticate — Submit the passcode, then guess a 2FA token derived from the hash of passcode + answer + randomized values

The hints in the description point to the attack: "Mr. Dam" → Merkle-Damgard construction, "UNIted" → combining/union of techniques.

Analysis

Component 1: RSA with phi leak

The Generator.encrypt() method has a critical vulnerability:

if GCD(self.phi, self.e) == 1: self.phi = None # phi hidden when RSA is "correct" # Otherwise phi is LEAKED in the response!

The 13 exponents are [65537, 4001, 11093, 7727, 32189, 19373, 8192, 7867, 599741, 919, 3697, 227, 9613]. Exponent at index 6 is e = 8192 = 2^13, which is always non-coprime with phi = (p-1)(q-1) since phi is always even. This means phi is leaked in the response for this key pair.

With N and phi, we factor N via the quadratic: p + q = N - phi + 1, p * q = N.

However, since GCD(e, phi) = GCD(2^13, (p-1)(q-1)) > 1, standard RSA decryption (d = e^{-1} mod phi) doesn't work. We need modular nth-root extraction.

Component 2: Operator precedence bug in rxor

The rxor() method has a subtle Python operator precedence bug:

r2 = b2l(os.urandom(len(x))) & (1<<(8*len(x))-1)

Due to Python's precedence rules, - binds tighter than <<, so this evaluates as:

r2 = b2l(os.urandom(len(x))) & (1 << ((8*len(x)) - 1))

This produces a single-bit mask at position 8*len(x) - 1 (the MSB). For ASCII input where all bytes have MSB=0, b2l(random_bytes) & 2^(8n-1) is always 0. Therefore rxor always returns all-zeros for ASCII input, and the hash oracle returns the true hash_digest(input) every time.

Component 3: Merkle-Damgard length extension

The 2FA token is computed as:

token = hash_digest(passcode + answer + randomized[-16:] + randomized[:16])

Where hash_digest internally prepends key + extra and pads the whole thing. By crafting answer to include the padding bytes that complete pad(key + extra + oracle_msg), we create a length extension scenario: the hash state after processing pad(key + extra + oracle_msg) is exactly H (obtained from the oracle), and we can continue the hash computation offline.

Component 4: Limited brute-force space

The randomize() method replaces hex chars "abcdef01357" with pool chars "+%2$?!_8*4", leaving {2, 4, 6, 8, 9} unchanged. The effective charset is !$%*+24689?_ (12 characters). Since randomize() picks 5 characters, the search space is 12^5 = 248,832 — trivially brute-forceable.

Solution

Phase 1: RSA phi leak + nth-root → passcode recovery

  1. Query option 1 with choice=6 (e=8192). The response includes N, phi, and encrypted_passcode.
  2. Factor N using p + q = N - phi + 1 and the quadratic formula.
  3. For each prime factor, compute the modular 2^13-th root:
    • Since v_2(p-1) = 3 (i.e., p-1 = 2^3 * m with m odd), compute e' = 2^(13-3) = 1024 and d' = inverse(e', m).
    • Get z = c^{d'} mod p, so z = x^8 mod p.
    • Take 3 successive square roots using sqrt_mod to recover all candidates for x mod p.
  4. Combine roots mod p and mod q via CRT → 64 candidates. Filter for printable ASCII.

Phase 2: Hash oracle → intermediate state H

  1. Construct oracle_msg = passcode + \x00*padding (padded to ≥10 bytes to avoid infinite loop in rxor's while loop).
  2. Query the hash oracle multiple times with oracle_msg. Due to the rxor bug, the oracle returns the true hash_digest(oracle_msg) every time.
  3. This gives us H = hash_digest(oracle_msg) — the intermediate hash state after processing all blocks of pad(key + extra + oracle_msg).

Phase 3: Length extension attack on 2FA

  1. Compute the padding bytes that follow oracle_msg in pad(key + extra + oracle_msg).
  2. Set answer = extra_zeros + padding_bytes. This makes the 2FA hash input begin with exactly pad(key + extra + oracle_msg), so the hash state after those blocks is H (known).
  3. Submit the passcode and crafted answer. The server returns the 2FA token.

Phase 4: Offline brute-force

After state H, the hash processes:

  • Block A: randomized[-16:] = \x00*12 + \x00\x00\x00\x28constant (padding metadata, independent of the 5 random chars)
  • Block B: randomized[:16] = 5_chars + \x80 + \x00*10 — depends on 5 unknown bytes
  • Padding blocks: constant (determined by total message length)

Precompute state S_A after block A (constant). Then brute-force all 12^5 = 248,832 candidates for the 5 characters, computing the hash continuation from S_A for each and comparing with the observed token.

#!/usr/bin/env python3 """ Full exploit for HackTheBox - Infosekurus Query Phase 1: RSA phi leak + nth root -> passcode Phase 2: Hash oracle -> intermediate state H Phase 3: Merkle-Damgard length extension + offline brute-force -> 2FA bypass """ from pwn import * from Crypto.Util.number import GCD, long_to_bytes, bytes_to_long, inverse from Crypto.Cipher import AES from sympy.ntheory import sqrt_mod import json, sys, itertools from math import isqrt from collections import Counter context.log_level = "info" exponents = [65537, 4001, 11093, 7727, 32189, 19373, 8192, 7867, 599741, 919, 3697, 227, 9613] # --- Hash reimplementation --- def bxor(a, b): return bytes(x ^ y for x, y in zip(a, b)) def blshift(a, b): return bytes( (((x % 2 ** ((8 - y) % 8)) << y) | (x >> ((8 - y) % 8))) & 0xFF for x, y in zip(a, b) ) def pad(message): bit_bytes = (8 * len(message)) & 0xFFFFFFFFFFFFFFFF message += b"\x80" while len(message) % 32 != 28: message += b"\x00" message += bit_bytes.to_bytes(4, byteorder="big") return message def hash_continue(seed, prev_block, blocks): for blk in blocks: cipher = AES.new(blk, AES.MODE_ECB) seed = bxor(cipher.encrypt(blshift(seed, prev_block)), seed) prev_block = blk return seed # --- Comms --- def send_option(r, option): r.recvuntil(b"Option (json format) :: ") r.sendline(json.dumps({"option": str(option)}).encode()) def get_encrypted_passcode(r, choice): send_option(r, 1) r.recvuntil(b"private key (json format) :: ") r.sendline(json.dumps({"choice": choice}).encode()) line = r.recvline().decode().strip() while not line: line = r.recvline().decode().strip() return json.loads(line) def get_hash(r, data_hex): send_option(r, 2) r.recvuntil(b"hashing today ? (json format) :: ") r.sendline(json.dumps({"hash": data_hex}).encode()) line = r.recvline().decode().strip() while not line: line = r.recvline().decode().strip() return json.loads(line) # --- RSA --- def factor_from_phi(N, phi): s = N - phi + 1 disc = s * s - 4 * N d = isqrt(disc) if d * d != disc: return None, None p, q = (s + d) // 2, (s - d) // 2 return (p, q) if p * q == N else (None, None) def rsa_nth_root(ct, e_val, p, q): """Solve x^e = ct (mod p*q) for e=2^13, v_2(p-1)=v_2(q-1)=3""" N = p * q all_roots = [] for prime in [p, q]: pm1 = prime - 1 s = 0 m = pm1 while m % 2 == 0: s += 1 m //= 2 e_prime = pow(2, 13 - s) d_prime = inverse(e_prime % m, m) z = pow(ct, d_prime, prime) roots = [z] for _ in range(s): new_roots = [] for rv in roots: sr = sqrt_mod(rv, prime, all_roots=True) if sr: new_roots.extend(sr) roots = new_roots all_roots.append((prime, roots)) # CRT p1, r1 = all_roots[0] p2, r2 = all_roots[1] candidates = [] for mp in r1: for mq in r2: m = (mp * p2 * inverse(p2, p1) + mq * p1 * inverse(p1, p2)) % N candidates.append(m) return candidates # --- Main --- def main(): HOST, PORT = sys.argv[1], int(sys.argv[2]) r = remote(HOST, PORT) r.recvuntil(b"Options") # == PHASE 1: RSA == log.info("Phase 1: RSA - getting phi leak from index 6 (e=8192)") data = get_encrypted_passcode(r, 6) N, phi_val, ct = data["N"], data["phi"], data["encrypted_passcode"] p, q = factor_from_phi(N, phi_val) candidates = rsa_nth_root(ct, 8192, p, q) passcode = None for cv in candidates: cb = long_to_bytes(cv) if all(32 <= b < 127 for b in cb) and len(cb) < 50: passcode = cb break log.success(f"Passcode: {passcode}") # == PHASE 2: Hash oracle == log.info("Phase 2: Hash oracle - getting intermediate state H") oracle_msg = passcode if len(oracle_msg) < 10: oracle_msg = passcode + b"\x00" * (10 - len(passcode)) hashes = [] for _ in range(3): resp = get_hash(r, oracle_msg.hex()) if "hash" in resp: hashes.append(resp["hash"]) H_hex = Counter(hashes).most_common(1)[0][0] H = bytes.fromhex(H_hex) log.success(f"H = {H_hex}") KEY_LEN, EXTRA_LEN = 48, 16 prefix_len = KEY_LEN + EXTRA_LEN + len(oracle_msg) padded_full = pad(b"\x00" * prefix_len) padding_after_msg = padded_full[prefix_len:] last_oracle_block = padded_full[-16:] # == PHASE 3: Authenticate == extra_zeros = oracle_msg[len(passcode):] answer = extra_zeros + padding_after_msg send_option(r, 3) r.recvuntil(b"passcode (json format) :: ") r.sendline(json.dumps({"passcode": passcode.hex()}).encode()) r.recvuntil(b"2FA Activated: ") r.recvuntil(b" (json format) :: ") r.sendline(json.dumps({"answer": answer.hex()}).encode()) r.recvuntil(b"token = ") token_hex = r.recvuntil(b" ").decode().strip() token = bytes.fromhex(token_hex) r.recvuntil(b"(json format) :: ") # == PHASE 4: Offline brute-force == log.info("Phase 4: Offline brute-force of 5-char randomized value") rand_last16 = b"\x00" * 12 + (40).to_bytes(4, "big") padded_oracle_len = len(padded_full) full_msg_len = padded_oracle_len + 32 full_padded = pad(b"\x00" * full_msg_len) final_padding = full_padded[full_msg_len:] padding_blocks = [final_padding[i:i+16] for i in range(0, len(final_padding), 16)] block_A = rand_last16 cipher_A = AES.new(block_A, AES.MODE_ECB) S_A = bxor(cipher_A.encrypt(blshift(H, last_oracle_block)), H) possible_chars = sorted(set("+%2$?!_8*469")) total = len(possible_chars) ** 5 # 248,832 found = None for combo in itertools.product(possible_chars, repeat=5): five_chars = "".join(combo).encode() block_B = five_chars + b"\x80" + b"\x00" * 10 cipher_B = AES.new(block_B, AES.MODE_ECB) S_B = bxor(cipher_B.encrypt(blshift(S_A, block_A)), S_A) state = S_B prev_blk = block_B for pb in padding_blocks: cipher_pb = AES.new(pb, AES.MODE_ECB) state = bxor(cipher_pb.encrypt(blshift(state, prev_blk)), state) prev_blk = pb if state == token: found = five_chars log.success(f"MATCH! randomized[:5] = {five_chars}") break r.sendline(json.dumps({"token": found.hex()}).encode()) resp = r.recvall(timeout=5).decode() log.success(f"Response: {resp}") r.close() if __name__ == "__main__": main()

$ cat /etc/motd

Liked this one?

Pro unlocks every writeup, every flag, and API access. $9/mo.

$ cat pricing.md