Voke Cyber Security Advisory

CVE-2026-39878: Unauthenticated Stored XSS to Admin Account Takeover in Chamilo LMS

Louis Sanchez Published July 7, 2026 10 min read
CVECVE-2026-39878
GHSAGHSA-gcjp-f7jm-rrrg
SeverityCritical  CVSS v3.1 base score 9.3
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:N
WeaknessCWE-79 — Improper Neutralization of Input During Web Page Generation (Stored Cross-Site Scripting)
ProductChamilo LMS (open-source e-learning / learning management system, PHP)
AffectedAll versions ≤ 1.11.38 (issue present in every release before 1.11.40)
Fixed in1.11.40 (fix commit 823ed10)
Reported byLouis Sanchez — Voke Cyber
Vendor creditChamilo Association — accepted the report, shipped a fix, requested the CVE, and credited the researcher in the advisory
DisclosureReported 2026-03-30 via GitHub private vulnerability reporting. Advisory GHSA-gcjp-f7jm-rrrg and CVE-2026-39878 published 2026-07-07 alongside the 1.11.40 release.

Summary

Chamilo LMS sanitizes self-registration input with Security::remove_XSS() (HTMLPurifier) but does not neutralize double quotes in plain text, and its event-handler detection can be defeated by placing a null byte inside the handler keyword (on\x00load). The admin user list (main/admin/user_list.php) then renders each account's first and last name inside alt/title attributes of an avatar <img> without htmlspecialchars(). An unauthenticated attacker registers an account whose name breaks out of the attribute and reconstructs a live onload handler. When any administrator opens the user list — a routine action — the payload runs in the admin's session and creates a new platform administrator. Fixed in 1.11.40.

Background: Chamilo and self-registration

Chamilo LMS is a widely deployed open-source learning management system used by universities, schools, government training programs, and companies to deliver online courses. A single instance commonly holds the personal data, grades, coursework, and certificates of thousands of students and staff.

Many educational deployments enable self-registration so that learners can create their own accounts for open enrollment. That single configuration flag is the only prerequisite for this vulnerability. Where public sign-up is on, the entry point is fully unauthenticated.

Root cause: a sanitizer bypass meeting an unescaped sink

The bug lives at the seam between two files — where registration input is cleaned, and where that input is later rendered back into a page. Neither location is wrong in isolation. The sanitizer assumes the output layer will escape for context; the output layer assumes the input was already made safe. The null-byte bypass breaks the first assumption, and the missing htmlspecialchars() removes the safety net that would have caught it anyway.

1. The sanitizer bypass — main/auth/inscription.php (line 453)

The registration form sanitizes all input fields with Security::remove_XSS(), a wrapper around HTMLPurifier. HTMLPurifier is a solid library, but the way it was applied here left two gaps:

Conceptually, the attacker submits a first name shaped like this (the \x00 is a literal null byte between on and load):

// Submitted first name (null byte splits the handler keyword)
" on\x00load='/* attacker JavaScript */' "

// Stored value after Security::remove_XSS() strips the null byte:
" onload='/* attacker JavaScript */' "

The sanitized value is written to the database as the user's first (or last) name.

2. The unescaped sink — main/admin/user_list.php (lines 472-475)

When an administrator opens the user management screen, Chamilo builds the user table and places each account's name directly into the alt and title attributes of the avatar image without passing it through htmlspecialchars():

// VULNERABLE — name interpolated into attributes with no escaping
$photo = '<img
    src="'.$userPicture.'" width="22" height="22"
    alt="'.api_get_person_name($user[2], $user[3]).'"
    title="'.api_get_person_name($user[2], $user[3]).'" />';

Because the stored name still contains the attacker's crafted markup, the double quote closes the alt attribute early and the injected onload becomes a real attribute of the <img> element. The rendered markup looks like this:

<img src="/main/img/icons/32/unknown.png" width="22" height="22"
     alt="" onload='/* attacker JavaScript */' " Johnson"
     title="..." />

Critically, the image src is a valid avatar URL, so onload fires automatically the instant the image renders — no broken image, no click, no interaction beyond the admin navigating to the page.

Exploitation

Prerequisite: the target instance has self-registration enabled — a common configuration for open enrollment.

  1. The attacker navigates to the public registration page and creates an account, placing the null-byte-obfuscated payload in the first or last name field instead of a normal name.
  2. Security::remove_XSS() processes the input. The null byte defeats event-handler detection, and the payload is stored as the account name.
  3. The attacker waits. No further action is required on their side.
  4. An administrator opens the user management list as part of routine administration. The stored payload renders inside the unescaped attribute and onload fires in the admin's authenticated session.
  5. Chamilo loads jQuery on every page, so the payload uses $.getScript() to load an external script. That script reads the CSRF token from the admin user-creation form (/main/admin/user_add.php) and POSTs a request that creates a new platform-administrator account with attacker-chosen credentials.
  6. The attacker logs in as the new administrator with full control of the instance.

There is no phishing, no crafted link, and no privileged access needed to start the chain. The attacker registers and waits for an ordinary administrative action to fire the payload — that admin action is the "user interaction" reflected in the CVSS vector (UI:R).

Impact

Full administrative account takeover of the Chamilo instance, and through it:

In education, that user data is protected under regimes such as GDPR in Europe and FERPA in the United States, so an admin takeover here is not just a technical problem — it is a reportable data-protection event.

CVSS vector rationale

CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:N — base score 9.3 (Critical)

Remediation

  1. Upgrade to Chamilo LMS 1.11.40 or later. The release corrects both the sanitization of the registration input and the output encoding on the user management screen.
  2. Escape at the sink. The vendor fix wraps the name in htmlspecialchars(..., ENT_QUOTES, 'UTF-8') before interpolating it into the alt/title attributes:
// FIXED — escape for the attribute context before rendering
$personName = htmlspecialchars(
    api_get_person_name($user[2], $user[3]),
    ENT_QUOTES,
    'UTF-8'
);
$photo = '<img
    src="'.$userPicture.'" width="22" height="22"
    alt="'.$personName.'"
    title="'.$personName.'" />';

Additionally, strip null bytes from user input before passing it to HTMLPurifier, and audit every location where user-supplied data is placed inside HTML attributes to confirm htmlspecialchars() with ENT_QUOTES is applied consistently.

If you cannot upgrade immediately

Disabling public self-registration removes the unauthenticated entry point and reduces the issue to an authenticated one. This is a mitigation, not a fix — upgrading to 1.11.40 remains the correct action.

The broader lesson

This is a textbook "two half-safe layers" failure. The input layer trusted HTMLPurifier to make the value safe for any context; the output layer trusted that the input had already been sanitized. Contextual output encoding — escaping for the exact place the value lands, at the moment it is rendered — would have neutralized the payload regardless of what got past the input filter. A sanitizer that does not know the output context, and an output that does not escape for its own context, add up to a live vulnerability even though each component looks reasonable on its own.

Disclosure timeline

The Chamilo maintainers handled the report cooperatively — confirming and fixing the issue, requesting the CVE, and coordinating the public advisory in line with their security policy. Credit to them for a constructive disclosure process.

References

Found and reported by Louis Sanchez, Founder & Principal Security Consultant at Voke Cyber (OSCP, OSWA, CISSP, CCSK). Prior advisories: CVE-2026-48742 (Coolify), CVE-2026-35198 (HeyForm), CVE-2026-48507 (Snipe-IT), and CVE-2026-42318 (GLPI).

We find the bugs tools miss

A null byte splitting an event-handler keyword does not show up in a dependency scan. It surfaces when someone traces user input from the form field to every place it is rendered and asks whether each sink escapes for its own context. That is how we approach web application and API testing for clients across the Charlotte, NC area and nationwide.

Get a Quote