CVE: CVE-2026–41505 Severity: High (CVSS 8.7) CWE: CWE-338 — Use of Cryptographically Weak Pseudo-Random Number Generator Advisory: GHSA-rvx5–95mm-p77v Project: RELATE — Open-Source Course Management System Author: Ruslan Amrahov
Introduction
In April 2026, I discovered a security vulnerability in RELATE — an open-source course management system developed by Andreas Kloeckner at the University of Illinois. The vulnerability was rooted in a single line of Python code:
import randomTwo words. One import. But in a security-sensitive context, this was enough to put password reset tokens, email sign-in links, and exam ticket codes at risk of prediction by an attacker.
This is the technical story of how I found it, why it matters, and what makes it genuinely exploitable.
What is RELATE?
RELATE (Relates Educational Activities to Technology in Education) is a Django-based learning management system used by universities to deliver quizzes, flow-based assessments, and course materials. It is open-source and available on pip as relate-courseware.
The Vulnerable Code
Location 1: course/auth.py — make_sign_in_key()
def make_sign_in_key(user: User) -> str:
import hashlib
import random # ← Mersenne Twister, NOT cryptographic
from time import time
m = hashlib.sha1()
m.update(user.email.encode("utf-8"))
m.update(hex(random.getrandbits(128)).encode()) # ← predictable
m.update(str(time()).encode("utf-8"))
return m.hexdigest()This function generates tokens used for:
- Password reset links sent via email
- Email-based sign-in (magic link authentication)
- API authentication tokens
Location 2: course/exam.py — gen_ticket_code()
ticket_alphabet = "ABCDEFGHJKLPQRSTUVWXYZabcdefghjkpqrstuvwxyz23456789"
def gen_ticket_code():
from random import choice # ← PRNG, not CSPRNG
return "".join(choice(ticket_alphabet) for _i in range(8))This function generates 8-character codes used for exam access tickets.
Why random is the Wrong Tool Here
Python's random module is built on the Mersenne Twister algorithm — a pseudorandom number generator (PRNG) designed for statistical simulations and general-purpose randomness. It is fast, well-distributed, and widely tested.
But it has one critical weakness: it is not cryptographically secure.
The Core Problem: State Reconstruction
Mersenne Twister maintains an internal state of 624 × 32-bit integers (19,968 bits total). The key property that makes it dangerous in a security context is this:
If an attacker observes 624 consecutive 32-bit outputs from Mersenne Twister, they can completely reconstruct the internal state and predict every future output with 100% accuracy.
This is not a theoretical weakness — it is a well-documented and long-known property of MT19937. Libraries to perform this attack exist publicly.
Does SHA-1 Save Us?
The make_sign_in_key() function feeds the random output into a SHA-1 hash along with the user's email and the current timestamp:
m.update(user.email.encode("utf-8")) # known to attacker
m.update(hex(random.getrandbits(128)).encode()) # predictable after state recovery
m.update(str(time()).encode("utf-8")) # approximableLet's evaluate each component:
Input Attacker Knowledge user.email Known — attacker triggers reset for a target email random.getrandbits(128) Predictable after 624 MT outputs observed time() Approximable — server time can be estimated from response headers
Once the MT state is known, the attacker can enumerate all plausible time() values within a reasonable window (a few seconds) and brute-force the resulting SHA-1 hash space — which is dramatically smaller than the intended 2^160 SHA-1 space.
The Attack — Step by Step
Phase 1: MT State Recovery
The attacker creates many accounts on the RELATE instance and repeatedly triggers password reset requests. Each reset call invokes make_sign_in_key(), which calls random.getrandbits(128).
Since getrandbits(128) internally calls the MT generator four times (4 × 32 bits = 128 bits), the attacker needs approximately 156 reset requests to observe enough MT outputs (624 × 32-bit words) to reconstruct the full state.
156 password resets × 4 MT outputs = 624 MT words → full state recoveryPhase 2: Token Prediction
With the MT state recovered, the attacker can predict all future outputs of random.getrandbits(128). Combined with an estimated server timestamp (obtainable from HTTP Date response headers), they can compute the exact SHA-1 token that will be generated for any future reset request — including for a victim's account.
Phase 3: Account Takeover
1. Attacker triggers password reset for victim@university.edu
2. Server calls make_sign_in_key(victim_user)
3. Attacker already knows the random output (from Phase 1)
4. Attacker approximates time() from HTTP response headers
5. Attacker computes SHA-1(email + predicted_random + estimated_time)
6. Attacker uses the predicted token to reset victim's password
7. Full account takeoverExam Ticket Prediction
The gen_ticket_code() vulnerability is more straightforward. The 8-character codes are drawn from a 51-character alphabet using random.choice():
Theoretical entropy: log2(51^8) ≈ 45.6 bitsThis is already modest. After MT state recovery, the attacker can predict all future ticket codes — enabling unauthorized access to examinations.
Why CVSS Rates This as 8.7 High
CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:C/C:N/I:H/A:H
AV:N → Exploitable over the network
AC:H → High complexity (requires ~156 requests + state recovery)
PR:N → No account privileges needed to initiate the attack
UI:N → No user interaction required
S:C → Scope changed — attacker can affect other users (victims)
C:N → No direct confidentiality impact from the PRNG flaw itself
I:H → Full integrity impact — account takeover, grade tampering
A:H → Availability impact — exam access disruptionThe Scope: Changed designation is significant. It means a low-privileged attacker (or even an unauthenticated one) can impact users entirely separate from themselves — in this case, taking over any student or instructor account.
The Fix
The solution is replacing random with Python's secrets module, which uses the operating system's CSPRNG (/dev/urandom on Linux, CryptGenRandom on Windows).
Location 1 — make_sign_in_key():
# BEFORE — vulnerable
import random
from time import time
m = hashlib.sha1()
m.update(user.email.encode("utf-8"))
m.update(hex(random.getrandbits(128)).encode())
m.update(str(time()).encode("utf-8"))
return m.hexdigest()
# AFTER - secure
import secrets
return secrets.token_hex(32) # 256 bits of OS-level CSPRNG entropysecrets.token_hex(32) generates 32 bytes (256 bits) of cryptographically secure randomness directly from the OS. There is no internal state to reconstruct. No amount of observed outputs reveals anything about future outputs.
Location 2 — gen_ticket_code():
# BEFORE — vulnerable
from random import choice
return "".join(choice(ticket_alphabet) for _i in range(8))
# AFTER - secure
import secrets
return "".join(secrets.choice(ticket_alphabet) for _ in range(8))secrets.choice() is a drop-in replacement for random.choice() with cryptographic security. The API is identical — the implementation is not.
Timeline
April 16, 2026 — Vulnerability discovered during source code review
April 16, 2026 — Responsible disclosure email sent to inform@tiker.net
April 18, 2026 — Maintainer confirmed the vulnerability and deployed a fix
April 18, 2026 — Security advisory published: GHSA-rvx5-95mm-p77v
April 18, 2026 — CVE-2026-41505 assigned by GitHub CNAThe vulnerability was fixed within 48 hours of my report.
Lessons Learned
1. import random is a code smell in security contexts
Python's own documentation explicitly warns:
"Warning: The pseudo-random generators of this module should not be used for security purposes. For security or cryptographic uses, see the secrets module."
When reviewing Python code, any use of random in authentication, token generation, or access control logic should be treated as a finding until proven otherwise.
2. Composition does not create security
Adding SHA-1 on top of a weak PRNG does not fix the underlying weakness. If the inputs to the hash are predictable, the output is predictable — regardless of the hash function used.
3. Two locations, one root cause
Both make_sign_in_key() in auth.py and gen_ticket_code() in exam.py used the same wrong module. In a code audit, finding one instance of a pattern should always prompt a search for others.
4. Responsible disclosure works
From report to fix to CVE — 48 hours. Open-source maintainers who take security seriously respond quickly when researchers communicate clearly and provide actionable reports.
Conclusion
CVE-2026–41505 is a textbook example of CWE-338: a cryptographically weak PRNG used in a security-sensitive context. The fix is two lines of Python. The impact — had it been exploited — could have been account takeovers across every RELATE deployment worldwide.
Security is not about complex exploits. It is about knowing which tool belongs in which context. random belongs in simulations. secrets belongs in authentication.
Advisory: GHSA-rvx5–95mm-p77v
Fix commit: 2f68e16cd3b96d25c188c1aa3f7e13cdb15cdaeb
Credit: Ruslan Amrahov
All testing was performed on a local RELATE instance. No production systems were accessed or affected.