Hakari Domain 2
chall.py, remote service nc 34.126.187.50 5501hack10{flag_here}1. Challenge Overview
The challenge exposed a Python source file and a remote nc service. The first phase required guessing three consecutive 32-bit outputs from Python's random module. After that, the service revealed an "Encrypted Secret" and offered chosen-plaintext encryption and chosen-ciphertext decryption on a custom AES object where _sub_bytes had been patched out. The goal was to recover the hidden 16-byte secret and submit it back through the encryption oracle to trigger the flag.
2. Initial Reconnaissance
The first triage command was used to identify the local artifact:
1$ file chall.py23chall.py: Python script, ASCII text executableThe next step was to read the challenge logic directly:
1$ nl -ba chall.py | sed -n '1,260p'2 1 import os3 2 import random4 3 import sys5 4 6 5 from AES import AES7 6 8 7 JACKPOT_STREAK = 39 8 MAX_ATTEMPTS = 25010 9 11 10 12 11 def huh(*_args):13 12 return None14 13 15 14 def load_flag() -> bytes:16 15 return os.getenv("FLAG", "hack10{REDACTED}").encode()17 16 18 17 19 18 def read_guess(jackpot: bool) -> str:20 19 if jackpot:21 20 return input("Predict the next number or type 'exit': ").strip()22 21 return input("Guess the next number: ").strip()23 22 24 23 25 24 def bytes_to_int(block: bytes) -> int:26 25 return int.from_bytes(block, "big")27 26 28 27 29 28 def int_to_bytes(value: int) -> bytes:30 29 return value.to_bytes(16, "big")31 30 32 31 33 32 def main() -> None:34 33 seed = os.urandom(8)35 34 random.seed(seed)36 35 flag = load_flag()37 36 streak = 038 37 attempts = 039 38 jackpot = False40 39 41 40 print("Welcome to Hakari Domain 2.")42 41 print(f"Guess the next 32-bit number in [0, {2**32 - 1}].")43 42 print(f"Reach {JACKPOT_STREAK} correct guesses in a row to hit jackpot.")44 43 print(f"You have {MAX_ATTEMPTS} total attempts before the connection closes.")45 44 print()46 45 47 46 while attempts < MAX_ATTEMPTS:48 47 try:49 48 raw = read_guess(jackpot)50 49 except EOFError:51 50 print("\\nBye.")52 51 return53 52 54 53 if jackpot and raw.lower() == "exit":55 54 print("Leaving with your collected ciphertexts.")56 55 return57 56 58 57 try:59 58 guess = int(raw)60 59 except ValueError:61 60 print("Numbers only.")62 61 continue63 62 64 63 if not 0 <= guess < 2**32:65 64 print(f"Guess must be between 0 and {2**32 - 1}.")66 65 continue67 66 68 67 target = random.getrandbits(32)69 68 attempts += 170 69 71 70 if guess != target:72 71 streak = 073 72 print(f"Wrong. The number was {target}.")74 73 print(f"Attempts used: {attempts}/{MAX_ATTEMPTS}")75 74 continue76 75 77 76 streak += 178 77 print(f"Correct. Current streak: {streak}")79 78 80 79 if not jackpot and streak == JACKPOT_STREAK:81 80 jackpot = True82 81 print("Jackpot unlocked.")83 82 84 83 key = os.urandom(16)85 84 secret = os.urandom(16)86 85 cipher = AES(key)87 86 cipher._sub_bytes = huh88 87 secret_enc = cipher.encrypt(secret)89 88 90 89 print("Congratulations! You've hit the jackpot and unlocked the next phase.\\n")91 90 print("Keep predicting the next number to continue choosing actions.")92 91 print("Encrypted Secret:", secret_enc.hex())93 92 while True:94 93 next_target = random.getrandbits(32)95 94 prediction = int(input("Predict the next number or type -1 to exit: "))96 95 97 96 if prediction == -1:98 97 print("Leaving with your collected ciphertexts.")99 98 return100 99 101 100 if prediction != next_target:102 101 print(f"Wrong. The number was {next_target}.")103 102 return104 103 105 104 option = int(input("[1] encrypt, [2] decrypt: "))106 105 107 106 if option == 1:108 107 plaintext = bytes.fromhex(input("Input plaintext to encrypt in hex: "))109 108 assert len(plaintext) == 16110 109 111 110 ciphertext = cipher.encrypt(plaintext)112 111 print(f"enc(plaintext) = {bytes.hex(ciphertext)}")113 112 114 113 if plaintext == secret:115 114 print(flag)116 115 exit()117 116 118 117 elif option == 2:119 118 ciphertext = bytes.fromhex(input("Input ciphertext to decrypt in hex: "))120 119 assert len(ciphertext) == 16121 120 122 121 if ciphertext == secret_enc:123 122 print("No way!")124 123 continue125 124 126 125 plaintext = cipher.decrypt(ciphertext)127 126 print(f"dec(ciphertext) = {bytes.hex(plaintext)}")128 127 129 128 print(f"Attempts used: {attempts}/{MAX_ATTEMPTS}")130 129 131 130 print(f"{MAX_ATTEMPTS} attempts used. Connection closed.")132 131 133 132 134 133 if __name__ == "__main__":135 134 try:136 135 main()137 136 except KeyboardInterrupt:138 137 print("\\nInterrupted.", file=sys.stderr)I then probed the live service to confirm the prompt format and wrong-guess leak:
1$ python3 - <<'PY'2import socket, time3host='34.126.187.50'; port=55014s=socket.create_connection((host,port), timeout=10)5s.settimeout(1)6chunks=[]7try:8 while True:9 d=s.recv(4096)10 if not d: break11 chunks.append(d)12 if b'Guess the next number:' in d: break13except Exception:14 pass15print(b''.join(chunks).decode('utf-8','replace'))16s.sendall(b'0\\n')17time.sleep(0.3)18chunks=[]19try:20 while True:21 d=s.recv(4096)22 if not d: break23 chunks.append(d)24except Exception:25 pass26print('--- response ---')27print(b''.join(chunks).decode('utf-8','replace'))28s.close()29PY30Welcome to Hakari Domain 2.31Guess the next 32-bit number in [0, 4294967295].32Reach 3 correct guesses in a row to hit jackpot.33You have 250 total attempts before the connection closes.34Guess the next number: 35--- response ---36037Wrong. The number was 1146452643.38Attempts used: 1/25039Guess the next number: 3. Analysis / Forensics Path
The source exposed two independent weaknesses:
random.seed(os.urandom(8)) seeds CPython's Mersenne Twister from an 8-byte bytes object. For bytes version-2 seeding, Python internally expands this into a seed array containing the 8-byte seed plus its SHA-512 digest, then feeds that array into init_by_array. This can be reversed with only 8 carefully chosen MT outputs, not 624.
The AES object is intentionally broken with cipher._sub_bytes = huh. Removing SubBytes turns AES into an affine transformation over (GF(2^8))^16:
E(P) = A * P + b
where A is a 16x16 matrix over GF(2^8) and b = E(0^16).
For the PRNG recovery, the useful state relations were taken from the observed output indices:
1S[3], S[230]2S[4], S[231]3S[5], S[232]4S[6], S[233]After untempering those outputs, the recovered seed words corresponded to the original 8-byte seed:
1seed_low = K[16]2seed_high = K[17]3seed = (seed_high << 32) | seed_lowOnce the seed was known, all future random.getrandbits(32) outputs became predictable, allowing reliable entry into the jackpot phase.
For the broken AES phase, I only needed encryption queries:
1b = E(0^16)23col_i = E(e_i) XOR bwhere e_i is the 16-byte basis vector with a single 0x01 byte in position i. Those 16 columns reconstruct A. Then:
1secret = A^{-1} * (secret_enc XOR b)Relevant live values from the successful solve:
1recovered seed: b00fbf23b376e6d62Encrypted Secret: 5e3e53997ce1d44a1831b3ded9a370b43recovered secret: c18c874bd1b40d7cf9465d5d7e017dff4. Exploitation / Recovery
Exact command chain used to validate and execute the exploit:
1$ python3 -m py_compile solve_hakari_domain_2.py \&\& python3 solve_hakari_domain_2.py2Welcome to Hakari Domain 2.3Guess the next 32-bit number in [0, 4294967295].4Reach 3 correct guesses in a row to hit jackpot.5You have 250 total attempts before the connection closes.6Guess the next number: [+] recovered seed: b00fbf23b376e6d672052736238Correct. Current streak: 19Attempts used: 236/25010Guess the next number: 199542702111Correct. Current streak: 212Attempts used: 237/25013Guess the next number: 229214538514Correct. Current streak: 315Jackpot unlocked.16Congratulations! You've hit the jackpot and unlocked the next phase.17Keep predicting the next number to continue choosing actions.18Encrypted Secret: 5e3e53997ce1d44a1831b3ded9a370b419Predict the next number or type -1 to exit: [+] encrypted secret: 5e3e53997ce1d44a1831b3ded9a370b420[+] recovered secret: c18c874bd1b40d7cf9465d5d7e017dff21c18c874bd1b40d7cf9465d5d7e017dff22enc(plaintext) = 5e3e53997ce1d44a1831b3ded9a370b423b'hack10{22a41542ef29a7f60a4b7b46fcab6174}'Exact full solver script used:
1#!/usr/bin/env python32import random3import re4import socket5from typing import List, Tuple6HOST = "34.126.187.50"7PORT = 55018PRE_JACKPOT_OBSERVED = 2359BLOCK = 1610class Remote:11 def __init__(self, host: str, port: int):12 self.sock = socket.create_connection((host, port), timeout=10)13 self.sock.settimeout(10)14 self.buf = b""15 def recv_until(self, marker: bytes) -> bytes:16 while marker not in self.buf:17 chunk = self.sock.recv(4096)18 if not chunk:19 raise EOFError("remote closed connection")20 self.buf += chunk21 idx = self.buf.index(marker) + len(marker)22 out = self.buf[:idx]23 self.buf = self.buf[idx:]24 return out25 def sendline(self, line: str) -> None:26 self.sock.sendall(line.encode() + b"\\n")27 def close(self) -> None:28 self.sock.close()29def unshift_right(x: int, shift: int) -> int:30 res = x31 for _ in range(32):32 res = x ^ (res >> shift)33 return res \& 0xFFFFFFFF34def unshift_left(x: int, shift: int, mask: int) -> int:35 res = x36 for _ in range(32):37 res = x ^ ((res << shift) \& mask)38 return res \& 0xFFFFFFFF39def untemper(v: int) -> int:40 v = unshift_right(v, 18)41 v = unshift_left(v, 15, 0xEFC60000)42 v = unshift_left(v, 7, 0x9D2C5680)43 v = unshift_right(v, 11)44 return v \& 0xFFFFFFFF45def invert_step(si: int, si227: int) -> Tuple[int, int]:46 x = si ^ si22747 mti1 = (x \& 0x80000000) >> 3148 if mti1:49 x ^= 0x9908B0DF50 x = (x << 1) \& 0xFFFFFFFF51 mti = x \& 0x8000000052 mti1 = (mti1 + (x \& 0x7FFFFFFF)) \& 0xFFFFFFFF53 return mti, mti154def init_genrand(seed: int) -> List[int]:55 mt = [0] * 62456 mt[0] = seed \& 0xFFFFFFFF57 for i in range(1, 624):58 mt[i] = ((0x6C078965 * (mt[i - 1] ^ (mt[i - 1] >> 30))) + i) \& 0xFFFFFFFF59 return mt60def recover_kj_from_ji(ji: int, ji1: int, i: int) -> int:61 const = init_genrand(19650218)62 key = ji - (const[i] ^ ((ji1 ^ (ji1 >> 30)) * 1664525))63 return key \& 0xFFFFFFFF64def recover_ji_from_ii(ii: int, ii1: int, i: int) -> int:65 ji = (ii + i) ^ ((ii1 ^ (ii1 >> 30)) * 1566083941)66 return ji \& 0xFFFFFFFF67def recover_kj_from_ii(ii: int, ii1: int, ii2: int, i: int) -> int:68 ji = recover_ji_from_ii(ii, ii1, i)69 ji1 = recover_ji_from_ii(ii1, ii2, i - 1)70 return recover_kj_from_ji(ji, ji1, i)71def recover_seed_candidates(outputs: List[int]) -> Tuple[bytes, bytes]:72 if len(outputs) < 234:73 raise ValueError("need at least 234 outputs")74 s = [untemper(x) for x in outputs]75 i230_msb, i231 = invert_step(s[3], s[230])76 i231_msb, i232 = invert_step(s[4], s[231])77 i232_msb, i233 = invert_step(s[5], s[232])78 i233_msb, i234 = invert_step(s[6], s[233])79 i231 = (i231 + i231_msb) \& 0xFFFFFFFF80 i232 = (i232 + i232_msb) \& 0xFFFFFFFF81 i233 = (i233 + i233_msb) \& 0xFFFFFFFF82 seed_low = (recover_kj_from_ii(i233, i232, i231, 233) - 16) \& 0xFFFFFFFF83 seed_high_1 = (recover_kj_from_ii(i234, i233, i232, 234) - 17) \& 0xFFFFFFFF84 seed_high_2 = (85 recover_kj_from_ii((i234 + 0x80000000) \& 0xFFFFFFFF, i233, i232, 234) - 1786 ) \& 0xFFFFFFFF87 cand1 = ((seed_high_1 << 32) | seed_low).to_bytes(8, "big")88 cand2 = ((seed_high_2 << 32) | seed_low).to_bytes(8, "big")89 return cand1, cand290def select_seed(outputs: List[int], candidates: Tuple[bytes, bytes]) -> bytes:91 for cand in candidates:92 r = random.Random()93 r.seed(cand)94 trial = [r.getrandbits(32) for _ in range(len(outputs))]95 if trial == outputs:96 return cand97 raise ValueError("no seed candidate matched observed outputs")98def gf_mul(a: int, b: int) -> int:99 res = 0100 for _ in range(8):101 if b \& 1:102 res ^= a103 hi = a \& 0x80104 a = (a << 1) \& 0xFF105 if hi:106 a ^= 0x1B107 b >>= 1108 return res109def gf_pow(a: int, e: int) -> int:110 res = 1111 while e:112 if e \& 1:113 res = gf_mul(res, a)114 a = gf_mul(a, a)115 e >>= 1116 return res117def gf_inv(a: int) -> int:118 if a == 0:119 raise ZeroDivisionError("zero has no inverse in GF(2^8)")120 return gf_pow(a, 254)121def mat_inv(mat: List[List[int]]) -> List[List[int]]:122 n = len(mat)123 aug = [row[:] + [1 if i == j else 0 for j in range(n)] for i, row in enumerate(mat)]124 for col in range(n):125 pivot = None126 for row in range(col, n):127 if aug[row][col] != 0:128 pivot = row129 break130 if pivot is None:131 raise ValueError("matrix is singular")132 if pivot != col:133 aug[col], aug[pivot] = aug[pivot], aug[col]134 inv_pivot = gf_inv(aug[col][col])135 for j in range(2 * n):136 aug[col][j] = gf_mul(aug[col][j], inv_pivot)137 for row in range(n):138 if row == col or aug[row][col] == 0:139 continue140 factor = aug[row][col]141 for j in range(2 * n):142 aug[row][j] ^= gf_mul(factor, aug[col][j])143 return [row[n:] for row in aug]144def mat_vec_mul(mat: List[List[int]], vec: List[int]) -> List[int]:145 out = []146 for row in mat:147 acc = 0148 for a, b in zip(row, vec):149 acc ^= gf_mul(a, b)150 out.append(acc)151 return out152def xor_bytes(a: bytes, b: bytes) -> bytes:153 return bytes(x ^ y for x, y in zip(a, b))154def parse_wrong_number(blob: bytes) -> int:155 m = re.search(rb"Wrong\\. The number was (\\d+)\\.", blob)156 if not m:157 raise ValueError(f"failed to parse wrong-number response: {blob!r}")158 return int(m.group(1))159def parse_secret_enc(blob: bytes) -> bytes:160 m = re.search(rb"Encrypted Secret:\\s*([0-9a-fA-F]+)", blob)161 if not m:162 raise ValueError("failed to parse encrypted secret")163 return bytes.fromhex(m.group(1).decode())164def parse_ciphertext(blob: bytes) -> bytes:165 m = re.search(rb"enc(plaintext) = ([0-9a-fA-F]+)", blob)166 if not m:167 raise ValueError(f"failed to parse ciphertext: {blob!r}")168 return bytes.fromhex(m.group(1).decode())169def do_encrypt(remote: Remote, prediction: int, plaintext: bytes, expect_prompt: bool = True) -> Tuple[bytes, bytes]:170 remote.sendline(str(prediction))171 remote.recv_until(b"[1] encrypt, [2] decrypt: ")172 remote.sendline("1")173 remote.recv_until(b"Input plaintext to encrypt in hex: ")174 remote.sendline(plaintext.hex())175 if expect_prompt:176 blob = remote.recv_until(b"Predict the next number or type -1 to exit: ")177 else:178 try:179 blob = remote.recv_until(b"Predict the next number or type -1 to exit: ")180 except EOFError:181 blob = remote.buf182 remote.buf = b""183 try:184 while True:185 chunk = remote.sock.recv(4096)186 if not chunk:187 break188 blob += chunk189 except Exception:190 pass191 return parse_ciphertext(blob), blob192def main() -> None:193 remote = Remote(HOST, PORT)194 try:195 banner = remote.recv_until(b"Guess the next number: ")196 print(banner.decode(errors="replace"), end="")197 observed = []198 for _ in range(PRE_JACKPOT_OBSERVED):199 remote.sendline("0")200 blob = remote.recv_until(b"Guess the next number: ")201 observed.append(parse_wrong_number(blob))202 candidates = recover_seed_candidates(observed[:234])203 seed = select_seed(observed, candidates)204 print(f"[+] recovered seed: {seed.hex()}")205 rng = random.Random()206 rng.seed(seed)207 for got in observed:208 expect = rng.getrandbits(32)209 if expect != got:210 raise ValueError("prediction stream desynced before jackpot")211 for idx in range(3):212 guess = rng.getrandbits(32)213 remote.sendline(str(guess))214 marker = (215 b"Predict the next number or type -1 to exit: "216 if idx == 2217 else b"Guess the next number: "218 )219 blob = remote.recv_until(marker)220 print(blob.decode(errors="replace"), end="")221 secret_enc = parse_secret_enc(blob)222 print(f"[+] encrypted secret: {secret_enc.hex()}")223 zero = bytes(BLOCK)224 bias, _ = do_encrypt(remote, rng.getrandbits(32), zero)225 cols = []226 for i in range(BLOCK):227 pt = bytearray(BLOCK)228 pt[i] = 1229 ct, _ = do_encrypt(remote, rng.getrandbits(32), bytes(pt))230 cols.append(xor_bytes(ct, bias))231 matrix = [[cols[col][row] for col in range(BLOCK)] for row in range(BLOCK)]232 matrix_inv = mat_inv(matrix)233 secret_vec = mat_vec_mul(matrix_inv, list(xor_bytes(secret_enc, bias)))234 secret = bytes(secret_vec)235 print(f"[+] recovered secret: {secret.hex()}")236 final_ct, final_blob = do_encrypt(remote, rng.getrandbits(32), secret, expect_prompt=False)237 if final_ct != secret_enc:238 raise ValueError("recovered secret does not re-encrypt correctly")239 print(final_blob.decode(errors="replace"), end="")240 finally:241 remote.close()242if __name__ == "__main__":243 main()5. Flag
6. Summary of Approach & Key Takeaways
Read chall.py and confirm the two-stage design: a Python random gate followed by a broken AES oracle.
Probe the service once to verify that every wrong guess leaks the real random.getrandbits(32) output.
Collect 235 wrong outputs, untemper them, and recover the 8-byte CPython seed from the carefully chosen index pairs (3,230), (4,231), (5,232), (6,233).
Re-seed a local random.Random() instance with the recovered seed and predict the three jackpot values.
Use the encryption oracle to recover the affine AES parameters: b = E(0) and the 16 basis columns of the linear map A.
Invert A over GF(2^8) and solve secret = A^{-1} * (secret_enc XOR b).
Submit the recovered secret through the encryption oracle to trigger the flag path.
Forensics Category
===