HTB: Wayback Challenge

Wayback - HackTheBox Challenge Writeup

Challenge Information

FieldValue
NameWayback
CategoryMisc / Reversing
DifficultyMedium
Authord3vn0mi

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 arithmetic
srand(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

Terminal window
# The staged binary was empty; the real files were behind a zip password
unzip -o -P hackthebox wayback.zip -d work/
# -> V1 (17KB ELF, not stripped), decrypt.py

2. Reverse V1 to find the seeding logic

Terminal window
strings -a V1 | head -60
nm -C V1 | grep -i " [tT] " # locate generate_password, main
objdump -d -M intel --start-address=0x1249 --stop-address=0x1553 V1
objdump -d -M intel --start-address=0x1553 --stop-address=0x1750 V1

This 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 key
key = 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, datetime
from 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 = start
while 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:25
Seed : 699413773
Password : eWXtk*Oe%j5cof7Od08G
Terminal window
echo 'eWXtk*Oe%j5cof7Od08G' | python3 decrypt.py
# -> "...d 30 Bitcoins! , HTB{REDACTED}"

Flag: HTB{REDACTED}

Tools Used

  • unzip — extracting the password-protected challenge archive
  • strings, nm, objdump — static reverse engineering of the V1 ELF
  • Python ctypes bound to system libc — reproducing glibc’s exact rand()/srand() behavior instead of reimplementing the PRNG
  • pycryptodome (via decrypt.py) — AES-256-CBC decryption of the wallet ciphertext

Key Learnings

  • Time-seeded RNGs are a classic weakness: seeding rand() (or any non-CSPRNG) from time() 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() via ctypes sidesteps 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.