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 random

Two 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.pymake_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.pygen_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"))            # approximable

Let'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 recovery

Phase 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 takeover

Exam 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 bits

This 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 disruption

The 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 entropy

secrets.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 CNA

The 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.