N
Back to writeup archive
Hack@10 International Capture The Flag Pre-Eliminary Round 2026

Hakari Domain 2

Remote crypto service analysis and scripted recovery for the second Hakari challenge.

CryptoMarkdown + PDFMediumsolved2 minread·505words
route
/TryN3rr0r/writeups/hack10-pre-eliminary-2026/hakari-domain-2
archive index
3 / 15
read mode
parsed markdown
Writeup loaded.

Hakari Domain 2

challenge brief
target profile, scoring, and execution stack
verified solve
target
chall.py, remote service nc 34.126.187.50 5501
category
Crypto
points
490
difficulty
Hard
flag format
hack10{flag_here}
tools used
bashfilenlsedpython3
final flag
hack10{22a41542ef29a7f60a4b7b46fcab6174}
reveal and copy from section 5

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.

PDF evidence frame 01

2. Initial Reconnaissance

The first triage command was used to identify the local artifact:

bash3 lines
1$ file chall.py23chall.py: Python script, ASCII text executable

The next step was to read the challenge logic directly:

bash138 lines
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:

bash39 lines
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: 
PDF evidence frame 02

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:

bash4 lines
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:

bash3 lines
1seed_low  = K[16]2seed_high = K[17]3seed      = (seed_high << 32) | seed_low

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

bash3 lines
1b = E(0^16)23col_i = E(e_i) XOR b

where e_i is the 16-byte basis vector with a single 0x01 byte in position i. Those 16 columns reconstruct A. Then:

bash1 lines
1secret = A^{-1} * (secret_enc XOR b)

Relevant live values from the successful solve:

bash3 lines
1recovered seed: b00fbf23b376e6d62Encrypted Secret: 5e3e53997ce1d44a1831b3ded9a370b43recovered secret: c18c874bd1b40d7cf9465d5d7e017dff
PDF evidence frame 03

4. Exploitation / Recovery

Exact command chain used to validate and execute the exploit:

bash23 lines
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:

python243 lines
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()
PDF evidence frame 04

5. Flag

captured flag
hack10{22a41542ef29a7f60a4b7b46fcab6174}

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

===