The Null Byte That Walked Past HTMLPurifier: CVE-2026-39878 in Chamilo LMS

Louis Sanchez July 7, 2026 8 min read

Chamilo runs everything a user types at sign-up through HTMLPurifier — a mature, well-regarded sanitization library. On paper, that is the right control in the right place. So the interesting question in a stored-XSS hunt is never "did they sanitize?" It is "what does the sanitizer assume, and does the rest of the code hold up its end of that assumption?"

In Chamilo LMS, the answer was no on both sides. A single null byte slipped a live JavaScript event handler past the filter at registration, and a missing htmlspecialchars() on the admin user list handed it a place to fire. The result is CVE-2026-39878: an unauthenticated attacker who can register an account can take over the platform administrator's session — CVSS 9.3.

The short version

CVE-2026-39878 is an unauthenticated stored XSS (CWE-79) in Chamilo LMS self-registration. Placing a null byte inside an onload handler (on\x00load) defeats HTMLPurifier's event-handler detection; the null byte is stripped and a working handler is left in the stored name. The admin user list renders that name inside an image tag's alt/title attributes without escaping, so it fires the moment an admin opens the page — and the payload creates a new platform admin. Fixed in Chamilo 1.11.40. The maintainers responded well and credited the report.

Why an LMS is a target worth caring about

Chamilo is an open-source learning management system used by universities, schools, government training programs, and companies worldwide. A single instance at a university can hold the personal details, grades, and coursework of thousands of students. Full administrative access to that instance means reading and exporting everyone's personal data, changing grades and certificates, creating or deleting accounts, and modifying course content — which is a foothold for deeper compromise of the host.

In education, that data sits under GDPR in Europe and FERPA in the United States. An admin takeover here is not just a technical incident; it is a reportable data-protection event.

The setup: sanitize on the way in

Many Chamilo deployments enable self-registration so learners can create their own accounts. That is the whole prerequisite for this bug — where public sign-up is on, the entry point needs no login at all.

The registration handler (main/auth/inscription.php) does what you would want: it passes every field through Security::remove_XSS(), a wrapper around HTMLPurifier, before storing it. HTMLPurifier strips HTML tags and event-handler attributes out of markup. Against a normal <img onerror=...> payload, it wins.

But HTMLPurifier was doing two things this code path quietly depended on it not needing to do. First, it does not escape double-quote characters in a plain-text value — it is a markup sanitizer, not an attribute-context encoder. Second, its detection of event-handler keywords can be tricked. Drop a null byte between on and load, and the keyword on\x00load no longer matches as an event handler. HTMLPurifier removes the null byte during its own parse — and leaves the reconstructed onload=... intact in the output.

So a first name submitted as " on\x00load='...' " is stored as " onload='...' ". It passed the filter, and it is now sitting in the users table looking like a slightly odd display name.

The payoff: an attribute with no escaping

A stored payload is only half a stored XSS. It needs a sink — a place where that stored value is written back into a page without being escaped for the context it lands in. In Chamilo, that sink is the admin user management screen (main/admin/user_list.php), which builds the table of users and drops each account's first and last name straight into the alt and title attributes of a little avatar <img> tag — with no htmlspecialchars() anywhere in the path.

The double quote in the stored name closes the alt attribute early, and the injected onload becomes a real attribute on the image element. The browser strips the null byte during its own parse and reconstructs a complete, valid event handler. And because the image's src is a genuine avatar URL, onload fires automatically the instant the image renders. No broken image, no click — just an admin opening the user list, which is one of the most routine things an administrator does.

Neither file is wrong in isolation. The sanitizer assumed the output layer would escape for context. The output layer assumed the input had already been made safe. The null byte broke the first assumption, and the missing escape removed the safety net that would have caught it anyway. That gap between two reasonable-looking layers is exactly where stored XSS lives.

What the payload actually does

Chamilo loads jQuery on every page, so the handler does not need to carry a big inline script. It uses $.getScript() to pull an external script into the admin's authenticated session. That script reads the CSRF token from the admin user-creation form and POSTs a request that creates a brand-new platform-administrator account with attacker-chosen credentials.

From the attacker's side, the whole chain is: register, wait, log in as admin. There is no phishing email, no link to get clicked, no privileged access to start from. The "user interaction" the exploit depends on is simply an administrator doing their job.

For the full technical breakdown — the vulnerable sink, the fix, and the CVSS rationale — see the advisory.
Read the advisory

The fix

Chamilo 1.11.40 corrects both ends. The user list now wraps the name in htmlspecialchars(..., ENT_QUOTES, 'UTF-8') before it goes into the alt/title attributes, so a double quote can no longer break out of the attribute. That single change is enough to neutralize the payload regardless of what got through at registration — which is the real lesson here.

If you run Chamilo

Upgrade to 1.11.40 or later. If you cannot upgrade right away, disabling public self-registration removes the unauthenticated entry point and buys you time — but it is a mitigation, not the fix.

The disclosure

This was a clean disclosure. The Chamilo maintainers confirmed the report, shipped a fix the next day, requested the CVE themselves, and credited the finding in the advisory. No pushback, no dispute over severity — just a team that took a security report seriously and closed it out properly.

The takeaway for anyone building forms

"We sanitize with HTMLPurifier" is a sentence that ends a lot of security conversations too early. Input sanitization and output encoding are not interchangeable. A sanitizer cleans markup without knowing where the value will eventually be rendered; only the output layer knows it is about to sit inside a double-quoted HTML attribute, and only the output layer can escape for that specific context. When a value flows from a form field to an attribute, escape it at the sink — every time, no matter how confident you are in the filter it passed through earlier.

For the complete technical detail — the sanitizer bypass, the unescaped sink, the CVSS vector, and remediation — see the full advisory.

Stored XSS hides between two reasonable-looking layers

A form that sanitizes on input and a page that forgets to escape on output each look fine on their own. The vulnerability only appears when you trace the value from the field to every place it is rendered. That is the same path we walk when testing web applications and APIs for clients across the Charlotte, NC area and nationwide.

Get a Quote