Dev.to Security 🔐 Cybersecurity 👁 0 📖 4 min read

CVE-2026-38526 Deep Dive: Unrestricted PHP Upload RCE in Krayin CRM

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) Vulnera

CVE-2026-38526 Deep Dive: Unrestricted PHP Upload RCE in Krayin CRM

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 file shell.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/public disk is symlinked to the public web root via php artisan storage:link. Anything saved there is reachable by URL. That's fine for actual images — but a .php file 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:

  1. No extension allowlist — nothing restricts uploads to .jpg, .png, .gif, etc.
  2. No MIME/content validation — the Content-Type header is either trusted blindly or not checked at all.
  3. 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:

  1. Authenticate — a low-privileged authenticated user — no admin role required — can reach /admin/tinymce/upload.
  2. 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.

  1. 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.
  2. Code execution — a plain GET request to that URL is enough. The web server sees the .php extension, hands it to the PHP interpreter, and the embedded code runs with the web server's privileges (typically www-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 .env and 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.

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.