Summary
| Field | Value |
|---|---|
| CVE ID | CVE-2026-38526 |
| CVSS 3.1 | 9.9 (Critical) |
| CWE | CWE-434 (Unrestricted Upload of File with Dangerous Type) |
| Affected product | Webkul Krayin CRM v2.2.x (open-source Laravel CRM) |
| Vulnerable endpoint | POST /admin/tinymce/upload |
| Privileges required | Low — any authenticated user, admin rights not needed |
| User interaction | None |
The CVSS vector is AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H — a network-reachable flaw, low attack complexity, low privileges required, no user interaction, with high impact on confidentiality, integrity, and availability. In plain terms: if you can log in at all, you can take over the server.
Why this matters, in one sentence
Krayin CRM uses the TinyMCE rich-text editor for things like email templates and notes. When a user pastes or inserts an image into the editor, the browser uploads that image through an API endpoint. The problem: that endpoint never checks whether the uploaded file is actually an image. An attacker can submit a .php file instead of a .jpg, and the server stores it in a directory the web server will happily execute.
Source-level walkthrough
A typical vulnerable Laravel upload handler looks like this. It's a reconstruction of the pattern described in the public advisory and PoC — not a verbatim copy of the proprietary source file — but it captures exactly the logic flaw involved.
// Webkul\Admin\Http\Controllers\TinyMCE\TinyMCEController (conceptual reconstruction)
public function upload(Request $request)
{
// ① Grab the uploaded file as-is
$file = $request->file('file');
// ② Trust the client-supplied filename — no extension allowlist!
$fileName = $file->getClientOriginalName();
// ③ Store it directly in a web-accessible "public" disk
$path = $file->storeAs('tinymce', $fileName, 'public');
// ④ Hand the attacker the exact URL of what was just stored
return response()->json([
'location' => asset('storage/' . $path),
]);
}
Breaking it down line by line:
-
①
$request->file('file')reads the uploaded file straight from the request. No validation has happened yet. -
②
getClientOriginalName()trusts whatever filename the browser sent. If an attacker names the fileshell.php, the server accepts that name verbatim. This is a textbook case of trusting client-controlled input that should never be trusted server-side. -
③ This is the real killer. Laravel's
storage/app/publicdisk is symlinked to the public web root viaphp artisan storage:link. Anything saved there is reachable by URL. That's fine for actual images — but a.phpfile dropped in this directory becomes a script the web server will execute on request, not just serve. - ④ The response conveniently returns the exact path of the uploaded file, so the attacker doesn't even need to guess it.
Three layers of defense are missing here, any one of which would have stopped the attack:
-
No extension allowlist — nothing restricts uploads to
.jpg,.png,.gif, etc. -
No MIME/content validation — the
Content-Typeheader is either trusted blindly or not checked at all. - Storage in an executable path — uploaded files land under the PHP-interpreted web root instead of an isolated, non-executable location.
Attack flow
The attack proceeds in four stages:
-
Authenticate — a low-privileged authenticated user — no admin role required — can reach
/admin/tinymce/upload. - Upload the payload — a multipart request like this:
POST /admin/tinymce/upload HTTP/1.1
Host: target
Cookie: <authenticated session>
Content-Type: multipart/form-data; boundary=----boundary
------boundary
Content-Disposition: form-data; name="file"; filename="shell.php"
Content-Type: image/jpeg
<?php /* malicious code */ ?>
------boundary--
Note the Content-Type: image/jpeg header — it's entirely attacker-controlled. If the server trusts this header instead of inspecting the file's actual bytes (its magic number), this spoofed header is enough to sail through.
-
Stored without validation — the file lands in a public directory like
storage/tinymce/under its original filename, and the server returns the file's public URL in the response. -
Code execution — a plain
GETrequest to that URL is enough. The web server sees the.phpextension, hands it to the PHP interpreter, and the embedded code runs with the web server's privileges (typicallywww-data).
Authenticate → Upload file → Saved in webroot → Code execution
(valid (no ext. (PHP-exec. (RCE
account) check) path) achieved)
The upload itself is "just" a file write — the vulnerability exists because the write lands in a location the server will execute. That's the essence of CWE-434.
Impact
- Arbitrary command execution with web server process privileges
- Exposure of
.envand other config files, including database credentials - Potential for full system compromise
- Given this is CRM software, customer PII and sales data are directly at risk
- An estimated ~2,700 internet-facing instances are believed affected
Remediation
| Category | Action |
|---|---|
| Immediate | Block or restrict /admin/tinymce/upload at the WAF / reverse proxy |
| Short term | Disable PHP execution in upload directories (.htaccess with php_flag engine off, or an Nginx location block denying .php execution) |
| Code fix | Enforce both an extension allowlist and real MIME/content inspection |
| Structural | Never trust the client filename — generate a random name (UUID) server-side |
| Structural | Move uploads outside the web root, or to an isolated object store (e.g. S3) |
| Access control | Restrict upload capability to the roles that actually need it |
| Monitoring | Alert on unusual access to, or execution from, upload directories |
A corrected version of the handler looks roughly like this:
public function upload(Request $request)
{
$request->validate([
'file' => 'required|image|mimes:jpg,jpeg,png,gif,webp|max:4096',
]);
$file = $request->file('file');
// Server generates the filename — client input is never trusted
$fileName = Str::uuid() . '.' . $file->getClientOriginalExtension();
$path = $file->storeAs('tinymce', $fileName, 'public');
return response()->json([
'location' => asset('storage/' . $path),
]);
}
The mimes: rule validates the file's actual signature (magic bytes), not just its extension or Content-Type header — so header spoofing alone can no longer bypass it.


Top comments (0)