September 13, 2026
The Invitation Nobody Checked: Chamilo LMS CVE-2026–82535
How a predictable survey code turned an unescaped text box into a backdoor admin account in Chamilo LMS.
By Leonbytycii
7 min read
By Jon Bytyçi and Leon Bytyci
TL;DR: Chamilo LMS gates survey answers behind an "invitation code." One variant of that code is derived entirely from public data (a user ID and a survey code) with no check that the requester actually is that user; another variant is handed out fresh to anyone who asks, no login required. Either way, an attacker can submit a survey answer that the application attributes to someone else — and that answer is stored raw and echoed back with no escaping in the teacher/admin reporting view. The result, which we proved end to end in a real browser: an anonymous HTTP request that ends in a persistent backdoor administrator account.
Full disclosure of CVE-2026–82535, a vulnerability my brother Leon and I found, reported to VulnCheck, and that the maintainer has since fixed and published. The bug is patched — nothing here is a live zero-day. Everything below is validated against Chamilo's own source, including a byte-level diff against the vendor's actual fix.
1. What is CVE-2026–82535?
- CVE: CVE-2026–82535
- Product: Chamilo LMS — open-source e-learning software with roughly 70,000+ self-hosted deployments and 21M+ user accounts across them
- Affected: 1.11.0–1.11.40 and 2.0.0–2.0.3 · Fixed in: 1.11.42 and 3.0.0
- Weakness: CWE-79 (Stored XSS), reachable through CWE-639 (Authorization Bypass Through User-Controlled Key)
- Severity: CVSS v4.0 5.3 Medium · v3.1 6.1 Medium
- CNA: VulnCheck
In one sentence: Chamilo's survey feature checks that an invitation code exists, but not that it belongs to the person presenting it — letting an attacker submit an answer attributed to someone else, which is then rendered without escaping to whoever reviews the results.
Invitation code exists in the database
|
v
Server checks: does this code exist? (yes -> proceed)
Server does NOT check: does this code belong to YOU?
|
v
Attacker-controlled answer stored under someone else's identity, unsanitised
|
v
Teacher/admin opens the routine survey-results page
|
v
Answer rendered with no HTML escaping = script executes in their sessionInvitation code exists in the database
|
v
Server checks: does this code exist? (yes -> proceed)
Server does NOT check: does this code belong to YOU?
|
v
Attacker-controlled answer stored under someone else's identity, unsanitised
|
v
Teacher/admin opens the routine survey-results page
|
v
Answer rendered with no HTML escaping = script executes in their session2. Impact
The primitive here is stored XSS — nothing more, on its own. What raises the stakes well beyond a typical Medium-rated bug is what we did with that primitive, and it's worth being precise about the boundary between the two.
The session cookie is HttpOnly, so this is not cookie theft. The XSS runs same-origin in whichever browser opens the poisoned report, which means it can drive any request that browser's session is authorized to make. In our lab, we used that access to have the payload fetch() Chamilo's own main/admin/user_add.php, read its CSRF token from the same page, and POST a new account with admin[platform_admin]=1 — a working, persistent platform-administrator account, created from a single anonymous HTTP request. That account survives even after the XSS itself is patched, which is the "backdoor account creation" VulnCheck's own advisory refers to.
To be precise: the vulnerability is stored XSS reachable pre-authentication. Admin-account creation is the specific, demonstrated consequence of that primitive in an administrator's session — not a separate flaw, and not evidence that credentials or session tokens were extracted directly.
3. How We Found It
Our approach to any Chamilo audit starts the same way: find the pages that explicitly tolerate a visitor with no account, because that's the widest door into the application.
Plaintext
grep -rn "api_is_anonymous" main/survey/grep -rn "api_is_anonymous" main/survey/main/survey/fillsurvey.php came back immediately — Chamilo intentionally lets surveys accept anonymous responses, which is a legitimate feature, not a misconfiguration. The interesting question wasn't "can anonymous people answer surveys" — the vendor clearly meant for that to be possible — it was "what actually stops someone from answering as somebody else?" Reading the file for whatever gated access, one comment on an unrelated function stopped us:
/**
* WARNING: this value is derived from the clock (time() + uniqid()) and is
* therefore predictable. It must never be used for security-sensitive,
* unguessable values such as password reset or e-mail confirmation tokens.
* Use api_generate_secure_token() instead.
*/
function api_get_unique_id() { ... }/**
* WARNING: this value is derived from the clock (time() + uniqid()) and is
* therefore predictable. It must never be used for security-sensitive,
* unguessable values such as password reset or e-mail confirmation tokens.
* Use api_generate_secure_token() instead.
*/
function api_get_unique_id() { ... }Chamilo's own source names the mistake to look for. We went hunting for token generation nearby — and the actual access-control token for surveys, the invitation code, turned out to be built two different ways, one of them not even as strong as the flawed function the comment warns about:
if ($isAnonymous) {
$autoInvitationcode = 'auto-ANONY_'.md5(time())."-$surveyCode"; // time-derived, ~86,400/day
} else {
$autoInvitationcode = "auto-$userid-$surveyCode"; // built from PUBLIC data only
}if ($isAnonymous) {
$autoInvitationcode = 'auto-ANONY_'.md5(time())."-$surveyCode"; // time-derived, ~86,400/day
} else {
$autoInvitationcode = "auto-$userid-$surveyCode"; // built from PUBLIC data only
}That second line has no secret material in it at all — it's a template filled in from a sequential user ID and a survey code Chamilo lists to anonymous visitors elsewhere. From there we traced forward: what validates this code, and what can you do once it's accepted?
4. Root Cause: Existence Checked, Ownership Never Was
It's tempting to call the invitation code "proof of identity," but that overstates what it was designed to be — it's an authorization credential, and the flaw is specifically that possessing it was treated as sufficient, with no check binding it to the person presenting it:
$sql = "SELECT * FROM $table_survey_invitation WHERE c_id=$course_id AND invitation_code='$code'";
if (Database::num_rows($result) < 1) {
api_not_allowed(true, get_lang('WrongInvitationCode'));
}
// Row found -> proceed as that invitation's user. Nothing checks WHO is asking.$sql = "SELECT * FROM $table_survey_invitation WHERE c_id=$course_id AND invitation_code='$code'";
if (Database::num_rows($result) < 1) {
api_not_allowed(true, get_lang('WrongInvitationCode'));
}
// Row found -> proceed as that invitation's user. Nothing checks WHO is asking.The two invitation-code variants fail this in different ways, and it matters which is which:
auto-$userid-$surveyCode(a real, already-invited user): fails because the code carries no secret at all — anyone who knows or enumerates a course code, survey code, and target user ID can reconstruct it and answer as that person, without ever logging in.auto-ANONY_<md5(time)>-$surveyCode(a fresh anonymous respondent): the attacker doesn't even need to guess anything — the endpoint mints a brand-new, fully valid code for anyone who asks. Self-service anonymous response is intentional; the bug is that the same "does this code exist" check also gated the first case.
Either path lands in the same place: c_survey_answer.option_id — a longtext column with no length cap — storing the submitted text completely unsanitised, next to a reporting page that renders it with none of the escaping Chamilo already applies to the question text on the very same page.
A smaller companion bug in the same code path: the "already answered" guard, $survey_invitation['answered'] == 1 && !isset($_GET['user_id']), is skipped entirely by appending &user_id=1, so an attacker isn't limited to first-answer submissions either.
5. Exploitation
Reproduced against a from-scratch Chamilo 1.11.40 lab, using a survey with anonymous responses enabled — again, an intentional feature, not a misconfiguration.
Get an invitation, no account required:
GET /main/survey/fillsurvey.php?course=POCSURVEYCOURSE&invitationcode=auto&scode=POCSURVEY HTTP/1.1GET /main/survey/fillsurvey.php?course=POCSURVEYCOURSE&invitationcode=auto&scode=POCSURVEY HTTP/1.1The server mints auto-ANONY_[MD5]-POCSURVEY on the spot and inserts it as a valid invitation. No credentials presented.
Submit the payload as that invitation:
POST /main/survey/fillsurvey.php?...&invitationcode=auto-ANONY_[MD5]-POCSURVEY&show=1 HTTP/1.1
question1=[INJECTED_PAYLOAD_HERE]POST /main/survey/fillsurvey.php?...&invitationcode=auto-ANONY_[MD5]-POCSURVEY&show=1 HTTP/1.1
question1=[INJECTED_PAYLOAD_HERE]Confirmed directly in the database — the raw bytes sent are the raw bytes stored, with no security sanitization functions and no HTML character escaping:
SELECT option_id FROM c_survey_answer WHERE survey_id=1 AND question_id=1; — [INJECTED_PAYLOAD_HERE]SELECT option_id FROM c_survey_answer WHERE survey_id=1 AND question_id=1; — [INJECTED_PAYLOAD_HERE]An administrator opens the ordinary results page — routine usage, not a special trick:
GET /main/survey/reporting.php?survey_id=1&action=completereport HTTP/1.1GET /main/survey/reporting.php?survey_id=1&action=completereport HTTP/1.1The response contains the payload verbatim inside a live document body:
[DIV_ELEMENT_WITH_PAYLOAD][DIV_ELEMENT_WITH_PAYLOAD]That script executes and, in our lab, created the backdoor administrator described in §2. One precise distinction worth making: reporting.php has a third view of the same stored answer, action=userreport (the per-respondent report), which happens to escape correctly because it renders the value through a form textarea that HTML-encodes its default. That is a different action than the two we tested — action=completereport and action=questionreport — both of which we confirmed render the payload unescaped. Severity depends on which of the three an administrator happens to open.
6. The Fix
We diffed the vendor's actual patch (the 1.11.x branch head, which carries the 1.11.42 fix ahead of a formal tag) against the vulnerable release, rather than take the advisory's word for it. It's three coordinated changes, addressing all three angles above:
- The anonymous code is now unguessable:
- $autoInvitationcode = ‘auto-ANONY_’.md5(time()).”-$surveyCode”;
+ $autoInvitationcode = ‘auto-ANONY_’.api_generate_secure_token(16).”-$surveyCode”;- $autoInvitationcode = ‘auto-ANONY_’.md5(time()).”-$surveyCode”;
+ $autoInvitationcode = ‘auto-ANONY_’.api_generate_secure_token(16).”-$surveyCode”;- The user-specific code now has to belong to whoever is asking. The deterministic
auto-$userid-$surveyCodeformat wasn't removed — it's now checked against the requester's actual session:
$isInvitedUser = $currentUserId > 0 && (string) $currentUserId === $invitedUser;
if (0 === strpos($invitation_code, ‘auto-’) && ctype_digit($invitedUser) && (int) $invitedUser > 0 && !$isInvitedUser) {
api_not_allowed(true, get_lang(‘WrongInvitationCode’));
}$isInvitedUser = $currentUserId > 0 && (string) $currentUserId === $invitedUser;
if (0 === strpos($invitation_code, ‘auto-’) && ctype_digit($invitedUser) && (int) $invitedUser > 0 && !$isInvitedUser) {
api_not_allowed(true, get_lang(‘WrongInvitationCode’));
}- Both output sinks we exploited now escape on the way out:
- echo $row[‘option_id’].’<hr noshade=”noshade” size=”1" />’;
+ echo Security::remove_XSS($row[‘option_id’]).’<hr noshade=”noshade” size=”1" />’;- echo $row[‘option_id’].’<hr noshade=”noshade” size=”1" />’;
+ echo Security::remove_XSS($row[‘option_id’]).’<hr noshade=”noshade” size=”1" />’;(and the identical fix in the complete-report row builder)
Chamilo 3.0.0 took a more radical approach for the same class of bug: rather than patch the sink, the entire legacy endpoint was retired. main/survey/fillsurvey.php in 3.0.0 is a stub that unconditionally denies access, with a comment stating that answering now happens through a new API that verifies the invitation belongs to the requester. We also confirmed the identical unpatched flaw exists in Chamilo 2.0.0–2.0.3, matching the advisory's affected range exactly.
If you run Chamilo, update to 1.11.42 or later, or migrate to 3.0.0.
7. What This Vulnerability Teaches
- Checking that a credential exists isn't the same as checking who holds it. The fix didn't remove the predictable code format — it added a binding check against the requester's actual identity. That's a different, stronger fix than "make the token longer."
- A feature built for one trust level can leak into another. Anonymous self-service answering was intentional; letting the same code format authorize answering as a known, already-invited user was not.
- Fix the sink and the source. This bug needed both a missing identity check and missing output escaping — the vendor's patch closes both.
- A recently audited codebase can still hide a bug in an unglamorous corner. Chamilo had just been through a large coordinated public security review. Surveys weren't part of it.
Conclusion
Nothing here required breaking cryptography or finding a memory-safety bug — just an authorization check answering the wrong question. "Does this invitation exist?" instead of "does it belong to you?" sat one unescaped output away from a script running in an administrator's browser. The vendor's fix confirms which question mattered: they didn't just encode the output, they added the identity check that should have been there from the start.
About the Authors
- Jon Bytyçi (Medium · LinkedIn) is a cybersecurity student at CyberAcademy in Prishtina, focused on offensive security, bug bounty, CVE research, and AI security research, and developer of the open-source Eclipse Security Suite.
- Leon Bytyçi (Medium · LinkedIn) is a cybersecurity student at CyberAcademy in Prishtina, vulnerability researcher focused on offensive and defensive security, bug bounty, security research, and CVEs.
We reported CVE-2026–82535 and coordinated its disclosure with the vendor.
Acknowledgments
Special thanks to CyberAcademy and Përparim Mjeku for his mentorship and guidance. His support and encouragement have played an important role in our technical growth and learning journey.
References
- VulnCheck Advisory: Chamilo LMS CVE-2026–82535 Stored XSS via Survey Answer Submission in reporting.php.
- MITRE CVE Program: CVE-2026–82535: Chamilo LMS Unauthenticated Stored XSS to Admin Takeover.
- MITRE Corporation CWE-79: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting').
- MITRE Corporation CWE-639: Authorization Bypass Through User-Controlled Key.
- Chamilo Association Website: Open-Source E-Learning Software.