UMCS Preliminary
Makmal Buta, UM
UMCS Preliminary umcs preliminary, forensics, reverse engineering writeup covering Makmal Buta, UM with analysis, solution steps, and final recovery notes.
Challenge Overview
Challenge Name: Makmal Buta, UM
Category: Misc / Crypto
Points: 490
Flag Format: UMCS{}
The challenge provides a long Braille-encoded message and two useful files:
-
output.txt— a recovered terminal output showingarkib_makmal_buta.txt, containing many log entries with hexadecimal IDs. -
loganathan_translated_to_py3.py— a partially corrupted Python 3 translation of an old script.
The goal is to recover the hidden room number / flag from the archive logs.
The important evidence is that the archive contains hundreds of ID: 0x........ values, while the corrupted Python script references extracting IDs, a .submit() method, prediction, XOR, and ASCII conversion.
Initial Analysis
The Braille text translates into an urgent message explaining that an old Python script was recovered, but most of it was corrupted. The message also says the log IDs are hexadecimal and that a “hex number generator” style function is probably needed.
The recovered Python script contains the key skeleton:
import re
# another import but couldn't find out what
def solve():
with open("arkib_makmal_buta.txt", "r") as f:
content = f.read()
ids = re(r"ID: (0x[0-9a-fA-F]{8})", content)
for i in range():
# something here
# some type cast maybe here
# some .submit method
encrypted = ids['''some integer here''':]
room_number = ""
for x in room_number:
# something about predicting
# type cast
# variables with ^ being used
room_number = room_number + '''corrupted variable(s)'''
return room_number
Several parts are damaged, but the intent is clear:
-
Read the archive file.
-
Extract all 32-bit hex IDs.
-
Submit some IDs into a predictor.
-
Treat the remaining IDs as encrypted data.
-
Predict values.
-
XOR encrypted values with predicted values.
-
Convert the result into ASCII characters.
The .submit() hint is very strong. In CTF crypto challenges, this commonly points to randcrack, a Python library used to clone Python’s MT19937 random generator after seeing 624 outputs.
Vulnerability / Weakness Identification
The weakness is improper use of MT19937 as an encryption keystream.
MT19937 is the pseudorandom number generator used by Python’s random module. It is not cryptographically secure. Its internal state consists of 624 32-bit values.
If an attacker observes 624 consecutive 32-bit outputs, they can reconstruct the full internal PRNG state. After that, every future output is predictable.
In this challenge:
-
Each archive
IDis a 32-bit hexadecimal value. -
The first 624 IDs are leaked MT19937 outputs.
-
The remaining IDs are encrypted flag characters.
-
Encryption is effectively:
cipher_value = ord(flag_char) ^ random.getrandbits(32)
Therefore decryption is:
flag_char = cipher_value ^ predicted_random_value
The recovered log contains 637 IDs total:
624 PRNG state outputs
+ 13 encrypted values
= 637 total IDs
The flag length is 13 characters, matching:
UMCS{NMBRXD3}
Exploitation Strategy
The exploitation plan is:
-
Extract every hexadecimal ID from
output.txt. -
Convert each ID from hex string to integer.
-
Use the first 624 integers to reconstruct the MT19937 internal state.
-
Predict the next 13 outputs.
-
XOR each encrypted ID from index 624 onward with the predicted PRNG output.
-
Convert the XOR result into characters.
-
Join the characters to recover the flag.
This works because XOR is reversible:
cipher = plaintext ^ key
plaintext = cipher ^ key
Since MT19937 can be cloned after 624 outputs, the “key” stream can be predicted exactly.
Proof of Concept
First, extract the IDs:
python3 - <<'PY'
import re
content = open("output.txt", "r", encoding="utf-8", errors="ignore").read()
ids = re.findall(r"ID:\s*(0x[0-9a-fA-F]{8})", content)
print("Total IDs:", len(ids))
print("First ID:", ids[0])
print("ID 624:", ids[623])
print("First encrypted ID:", ids[624])
PY
Expected result:
Total IDs: 637
First ID: 0x75ae1757
ID 624: 0x088c3d6b
First encrypted ID: 0xcb7cd75e
The first 624 IDs are enough to clone MT19937. The remaining 13 IDs are decrypted by XORing them with predicted future outputs.
Full Python Solver
This solver is dependency-free. It does not require randcrack; it implements MT19937 state recovery directly.
Save it as:
solve_makmal_buta.py
#!/usr/bin/env python3
import re
import sys
MASK_32 = 0xFFFFFFFF
def undo_right_shift_xor(y: int, shift: int) -> int:
"""
Reverse: y = x ^ (x >> shift)
For right-shift XOR, recover bits from MSB to LSB because each
lower bit depends on a higher bit.
"""
x = 0
for i in range(31, -1, -1):
shifted_bit = ((x >> (i + shift)) & 1) if (i + shift) < 32 else 0
bit = ((y >> i) & 1) ^ shifted_bit
x |= bit << i
return x & MASK_32
def undo_left_shift_xor_and(y: int, shift: int, mask: int) -> int:
"""
Reverse: y = x ^ ((x << shift) & mask)
For left-shift XOR, recover bits from LSB to MSB because each
higher bit depends on a lower bit.
"""
x = 0
for i in range(32):
shifted_bit = ((x >> (i - shift)) & 1) if (i - shift) >= 0 else 0
mask_bit = (mask >> i) & 1
bit = ((y >> i) & 1) ^ (shifted_bit & mask_bit)
x |= bit << i
return x & MASK_32
def temper(y: int) -> int:
"""
MT19937 tempering function.
This is applied before an internal state value is returned as output.
"""
y ^= y >> 11
y ^= (y << 7) & 0x9D2C5680
y ^= (y << 15) & 0xEFC60000
y ^= y >> 18
return y & MASK_32
def untemper(y: int) -> int:
"""
Reverse MT19937 tempering.
Given an observed 32-bit output, recover the corresponding internal
state value.
"""
y = undo_right_shift_xor(y, 18)
y = undo_left_shift_xor_and(y, 15, 0xEFC60000)
y = undo_left_shift_xor_and(y, 7, 0x9D2C5680)
y = undo_right_shift_xor(y, 11)
return y & MASK_32
class MT19937Clone:
"""
Minimal MT19937 predictor.
After receiving exactly 624 observed 32-bit outputs, this class rebuilds
the internal state and predicts future outputs.
"""
N = 624
M = 397
MATRIX_A = 0x9908B0DF
UPPER_MASK = 0x80000000
LOWER_MASK = 0x7FFFFFFF
def __init__(self, observed_outputs):
if len(observed_outputs) != self.N:
raise ValueError(f"Need exactly {self.N} observed outputs")
self.state = [untemper(value) for value in observed_outputs]
self.index = self.N
def twist(self):
"""
Generate the next MT19937 state block.
"""
for i in range(self.N):
y = (
(self.state[i] & self.UPPER_MASK)
| (self.state[(i + 1) % self.N] & self.LOWER_MASK)
)
self.state[i] = self.state[(i + self.M) % self.N] ^ (y >> 1)
if y & 1:
self.state[i] ^= self.MATRIX_A
self.state[i] &= MASK_32
self.index = 0
def predict_getrandbits_32(self) -> int:
"""
Predict the next 32-bit output.
Equivalent to random.getrandbits(32) for this cloned state.
"""
if self.index >= self.N:
self.twist()
y = self.state[self.index]
self.index += 1
return temper(y)
def solve(path: str) -> str:
"""
Recover the flag from the archive output.
The first 624 IDs are leaked MT19937 outputs.
The remaining IDs are encrypted characters.
"""
with open(path, "r", encoding="utf-8", errors="ignore") as f:
content = f.read()
ids = [
int(value, 16)
for value in re.findall(r"ID:\s*(0x[0-9a-fA-F]{8})", content)
]
print(f"[*] Extracted {len(ids)} IDs")
if len(ids) < 625:
raise ValueError("Not enough IDs to clone MT19937 and decrypt data")
known_outputs = ids[:624]
encrypted_values = ids[624:]
print(f"[*] Using first 624 IDs to clone MT19937")
print(f"[*] Decrypting {len(encrypted_values)} encrypted values")
predictor = MT19937Clone(known_outputs)
recovered = ""
for cipher_value in encrypted_values:
key = predictor.predict_getrandbits_32()
plain_value = cipher_value ^ key
if plain_value > 0x10FFFF:
raise ValueError(f"Invalid decoded character: {plain_value:#x}")
recovered += chr(plain_value)
return recovered
def main():
path = sys.argv[1] if len(sys.argv) > 1 else "output.txt"
flag = solve(path)
print(f"[+] Recovered flag: {flag}")
if __name__ == "__main__":
main()
Walkthrough
Place the solver in the same directory as output.txt:
ls
Expected:
output.txt
loganathan_translated_to_py3.py
solve_makmal_buta.py
Run the solver:
python3 solve_makmal_buta.py output.txt
Expected output:
[*] Extracted 637 IDs
[*] Using first 624 IDs to clone MT19937
[*] Decrypting 13 encrypted values
[+] Recovered flag: UMCS{NMBRXD3}
No external Python packages are required.
If using the simpler randcrack method, the equivalent logic would be:
from randcrack import RandCrack
import re
content = open("output.txt", "r", encoding="utf-8", errors="ignore").read()
ids = [int(x, 16) for x in re.findall(r"ID:\s*(0x[0-9a-fA-F]{8})", content)]
rc = RandCrack()
for value in ids[:624]:
rc.submit(value)
flag = ""
for value in ids[624:]:
flag += chr(value ^ rc.predict_getrandbits(32))
print(flag)
Install dependency only if using this shorter version:
pip3 install randcrack
Flag
The recovered flag is:
UMCS{NMBRXD3}
Conclusion
The challenge hides the flag behind a predictable PRNG-based XOR scheme. The archive leaks exactly enough MT19937 output to reconstruct the full generator state. Once the first 624 log IDs are submitted into an MT19937 predictor, the future keystream values are predictable, allowing the remaining encrypted IDs to be XOR-decrypted into the flag.
Key lesson:
MT19937 is suitable for simulation and general randomness, but it must never be used for cryptographic encryption, token generation, or secret keystreams. Once enough outputs are exposed, the entire future sequence becomes predictable.