HTB: Wayback Challenge
Wayback - HackTheBox Challenge Writeup
Challenge Information
| Field | Value |
|---|---|
| Name | Wayback |
| Category | Misc / Reversing |
| Difficulty | Medium |
| Author | d3vn0mi |
Description
Michael Tanz bought 30 bitcoin in 2013 and stored it in his hardware wallet. He generated the wallet password with a tool called “V1” — a 20-character string of alphanumerics and symbols. He doesn’t remember the exact moment he ran the generator, only that it was sometime between the 10th and the 11th of December, 2013. The goal: recover the password and decrypt Michael’s wallet to retrieve the flag.
Solution
The challenge ships a password-protected zip (password: hackthebox) containing two files: a 17KB non-stripped ELF binary called V1 (the password generator) and decrypt.py (an AES decryptor for the “wallet” ciphertext).
Reversing V1 showed it isn’t a cryptographically sound generator — it seeds glibc’s rand() with a value built entirely from the current wall-clock time, packed into decimal digit positions:
seed = mday * 1000000 + hour * 10000 + min * 100 + sec + (year + 1900) * 0x540be400 // wraps in 32-bit arithmetic + (mon + 1) * 0x5f5e100; // wraps in 32-bit arithmeticsrand(seed);Because the year and month terms wrap around in 32-bit arithmetic, the seed’s effective entropy collapses to just the day, hour, minute, and second — and since Michael already narrowed the date to a single day (Dec 10–11, 2013), the only real unknown left is the second the tool was run. That’s a search space of at most 172,800 seconds (48 hours) — trivially brute-forceable.
Once the seed is known, rand() % 72 selects each of the 20 password characters from a fixed charset (a-z, A-Z, plus symbols/digits depending on the yes/no prompts). The recovered password is then used directly as an AES-256-CBC key against the wallet ciphertext in decrypt.py to reveal the flag.
Key Steps
1. Recover the real artifacts from the protected zip
# The staged binary was empty; the real files were behind a zip passwordunzip -o -P hackthebox wayback.zip -d work/# -> V1 (17KB ELF, not stripped), decrypt.py2. Reverse V1 to find the seeding logic
strings -a V1 | head -60nm -C V1 | grep -i " [tT] " # locate generate_password, mainobjdump -d -M intel --start-address=0x1249 --stop-address=0x1553 V1objdump -d -M intel --start-address=0x1553 --stop-address=0x1750 V1This recovered the C-equivalent logic:
// main(): prompts for length (20), and two y/n flags (symbols?, digits?)generate_password(20, use_symbols, use_digits);
// generate_password():char charset[] = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" "!@#$%^&*_+" "0123456789"; // 72 chars total
time_t now = time(NULL);struct tm *t = localtime(&now);
unsigned int seed = t->tm_mday * 1000000 + t->tm_hour * 10000 + t->tm_min * 100 + t->tm_sec + (t->tm_year + 1900) * 0x540be400 + (t->tm_mon + 1) * 0x5f5e100;
srand(seed);for (int i = 0; i < 20; i++) pw[i] = charset[rand() % 72];3. Confirm the AES decryption scheme in decrypt.py
# decrypt.py (abridged): the password IS the AES keykey = password.encode().ljust(32, b'\x00')iv = ciphertext[:16]cipher = AES.new(key, AES.MODE_CBC, iv)plaintext = unpad(cipher.decrypt(ciphertext[16:]), AES.block_size)4. Brute-force the seed space using glibc’s real rand()
Rather than reimplement glibc’s TYPE_3 additive-feedback PRNG, the seed→password mapping was reproduced exactly by calling into libc via ctypes, iterating every second across the Dec 10–11, 2013 window and testing each generated password as the AES key:
import ctypes, datetimefrom ctypes.util import find_library
libc = ctypes.CDLL(find_library("c"))CHARSET = ("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" "!@#$%^&*_+0123456789")
start = datetime.datetime(2013, 12, 10)end = datetime.datetime(2013, 12, 12) # exclusive upper bound covers the 11th fully
t = startwhile t < end: seed = (t.day * 1_000_000 + t.hour * 10_000 + t.minute * 100 + t.second + (t.year) * 0x540be400 + t.month * 0x5f5e100) & 0xFFFFFFFF
libc.srand(ctypes.c_uint(seed)) pw = "".join(CHARSET[libc.rand() % 72] for _ in range(20))
if try_decrypt_as_key(pw): # AES-256-CBC decrypt + check for "HTB{REDACTED} / valid padding print(t, seed, pw) break
t += datetime.timedelta(seconds=1)5. Hit and decryption
Timestamp : 11 Dec 2013 13:01:25Seed : 699413773Password : eWXtk*Oe%j5cof7Od08Gecho 'eWXtk*Oe%j5cof7Od08G' | python3 decrypt.py# -> "...d 30 Bitcoins! , HTB{REDACTED}"Flag: HTB{REDACTED}
Tools Used
unzip— extracting the password-protected challenge archivestrings,nm,objdump— static reverse engineering of theV1ELF- Python
ctypesbound to systemlibc— reproducing glibc’s exactrand()/srand()behavior instead of reimplementing the PRNG pycryptodome(viadecrypt.py) — AES-256-CBC decryption of the wallet ciphertext
Key Learnings
- Time-seeded RNGs are a classic weakness: seeding
rand()(or any non-CSPRNG) fromtime()collapses whatever “password entropy” a tool advertises down to the entropy of the seed space — here, effectively just seconds-in-a-day. - Digit-packed seeds can look deceptively strong: cramming year/month/day/hour/min/sec into decimal digit slots multiplied by large constants looks like it spreads entropy across a huge 32-bit range, but 32-bit integer wraparound on the year/month terms means those fields contribute far less real entropy than they appear to.
- A narrowed real-world time window is a devastating hint: once the victim narrows the generation time to a ~48-hour window, a full second-by-second brute force (172,800 candidates) is computationally trivial — reinforcing why RNGs for secrets must never be time-seeded, regardless of the surrounding entropy claims.
- Reproduce libc exactly rather than reimplement it: calling into the real
libc.rand()/srand()viactypessidesteps subtle bugs from reimplementing glibc’s TYPE_3 additive-feedback generator by hand, guaranteeing bit-for-bit fidelity with the target binary’s output. - Password-protected challenge archives are common obfuscation, not real security: a 0-byte staged artifact was a signal to check the original zip directly rather than pivot away from the intended reversing path.