CVE-2026-77526
FacturaScripts: Path traversal in UploadedFile::move() via getClientOriginalName() — arbitrary file write outside MyFiles/ leading to RCE
Summary
## Summary `FacturaScripts\Core\UploadedFile::move($destiny, $destinyName)` concatenates `$destiny` and `$destinyName` without normalizing the resulting path. Every caller in the codebase passes `UploadedFile::getClientOriginalName()` — the unsanitized client-supplied filename — as `$destinyName`, so an authenticated user submitting a filename containing `../` segments can write the uploaded content to any directory writable by the web-server user, escaping the intended `MyFiles/` location. Because the shipped `htaccess-sample` (the documented production Apache configuration) excludes `Dinamic/Assets/` and `node_modules/` from the `index.php` rewrite, files written into those directories are served directly by Apache. Combined with `.htaccess` not being in `BLOCKED_EXTENSIONS`, the primitive escalates from arbitrary file write to remote code execution. ## Vulnerable Code `Core/UploadedFile.php`: ```php private const BLOCKED_EXTENSIONS = ['phar', 'php', 'php3', 'php4', 'php5', 'php7', 'php8', 'pht', 'phtml', 'phps']; public function move(string $destiny, string $destinyName): bool { if (!$this->isValid()) { return false; } if (substr($destiny, -1) !== DIRECTORY_SEPARATOR) { $destiny .= DIRECTORY_SEPARATOR; } return $this->test ? rename($this->tmp_name, $destiny . $destinyName) : move_uploaded_file($this->tmp_name, $destiny . $destinyName); } public function getClientOriginalName(): string { return $this->name ?? ''; } ``` `isValid()` only checks the extension blocklist, the upload error code, and `is_uploaded_file()` — it never inspects the filename for directory separators or `..` segments. Six call sites pass the raw client filename straight into `move()`: - `Core/Controller/ApiUploadFiles.php:58` — `POST /api/3/uploadfiles` - `Core/Controller/ApiAttachedFiles.php:136` — `POST /api/3/attachedfiles` - `Core/Lib/Widget/WidgetFile.php:84` — every form using a file widget - `Core/Lib/Widget/WidgetLibrary.php:215` — library widget upload - `Core/Lib/ExtendedController/DocFilesTrait.php:51` — document files trait - `Core/Controller/AdminPlugins.php:260` — plugin (zip) upload Representative sink — `Core/Controller/ApiUploadFiles.php:56-79`: ```php private function uploadFile(UploadedFile $uploadFile): ?AttachedFile { if (false === $uploadFile->isValid()) { return null; } $destiny = FS_FOLDER . '/MyFiles/'; $destinyName = $uploadFile->getClientOriginalName(); if (file_exists($destiny . $destinyName)) { $destinyName = mt_rand(1, 999999) . '_' . $destinyName; } if ($uploadFile->move($destiny, $destinyName)) { ... } } ``` Shipped `htaccess-sample` (production Apache rules): ```apache RewriteEngine On RewriteBase / RewriteCond %{REQUEST_URI} !Dinamic/Assets/ [NC] RewriteCond %{REQUEST_URI} !node_modules/ [NC] RewriteRule . index.php [L] ``` Apache therefore serves any file under `Dinamic/Assets/` directly, bypassing `index.php` entirely. ## PoC ### Step 1 — Static reproduction of the file-write primitive The following script replicates `UploadedFile::move()`'s `rename()` path verbatim inside a sandboxed temp directory. It does not run any payload — it only demonstrates that the destination escapes `MyFiles/` when the filename contains `../`. ```php <?php $base = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'fs_verify_' . uniqid(); mkdir($base); mkdir($base . '/MyFiles'); mkdir($base . '/Dinamic'); mkdir($base . '/Dinamic/Assets'); $tmp = $base . '/tmp_upload.dat'; file_put_contents($tmp, "static-verification-marker\n"); function fs_move($tmp_name, $destiny, $destinyName) { if (substr($destiny, -1) !== DIRECTORY_SEPARATOR) { $destiny .= DIRECTORY_SEPARATOR; } return rename($tmp_name, $destiny . $destinyName); } fs_move($tmp, $base . '/MyFiles', '../Dinamic/Assets/traversed.txt'); echo file_exists($base . '/Dinamic/Assets/traversed.txt')
Affected packages
| Ecosystem | Package | Affected versions | Fixed versions |
|---|---|---|---|
| Packagist | facturascripts/facturascripts | — | 2026.3 |
Remediation: Upgrade to 2026.3 or later.
References
Includes data from the GitHub Advisory Database, licensed under CC-BY 4.0.
CVE® is a registered trademark of The MITRE Corporation. CVE content reproduced under the CVE Terms of Use; copyright designation © MITRE.