Grav CMS SSRF via Unrestricted cURL Protocols
CVE-2026-62234: Grav CMS webhook dispatch allows file://, dict://, and gopher:// protocols, enabling authenticated SSRF and local file read.
Overview
CVE-2026-62234 is a Server-Side Request Forgery (SSRF) vulnerability in Grav CMS affecting all releases prior to 2.0.4. The flaw resides in the webhook dispatch subsystem, which constructs and executes cURL requests based on user-supplied URLs without enforcing any protocol allowlist. An authenticated user holding the api.webhooks.write permission can register a webhook endpoint using non-HTTP schemes — specifically file://, dict://, or gopher:// — and then trigger the associated event to coerce the server into fetching arbitrary local resources or probing internal network services.
The attack surface is constrained to authenticated users with a specific permission, which is reflected in the CVSS score of 8.1 (High) rather than Critical. However, in many Grav deployments this permission is granted broadly to content editors or API integrators, and the permission boundary should not be mistaken for meaningful security isolation. Once an attacker has api.webhooks.write, the path to reading /etc/passwd, enumerating process memory mappings via /proc/self/maps, or reaching unauthenticated internal services is straightforward and requires no additional exploitation.
Grav is a flat-file CMS written in PHP with a substantial installation base across agencies, developer blogs, and enterprise documentation portals. The webhook feature, introduced to support headless and JAMstack workflows, delegates HTTP dispatch to the PHP cURL extension without constraining which URI schemes that extension is permitted to use — a classic case of trusting a powerful primitive with insufficient guardrails.
Technical Analysis
The root cause is an absence of protocol validation in the webhook registration and dispatch pathway. When a webhook is created, Grav stores the caller-supplied URL verbatim. At dispatch time, the URL is passed directly to a cURL handle without scheme filtering:
// Vulnerable pattern — simplified from Grav webhook dispatcher (< 2.0.4)
class WebhookDispatcher
{
public function dispatch(Webhook $webhook, array $payload): ResponseInterface
{
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $webhook->getUrl(), // ← user-controlled, no scheme check
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
]);
$response = curl_exec($ch);
$error = curl_error($ch);
curl_close($ch);
if ($error) {
throw new WebhookException($error);
}
return new WebhookResponse($response);
}
}
PHP’s cURL extension is a thin wrapper around libcurl, which natively supports dozens of URI schemes. Unless CURLOPT_PROTOCOLS or CURLOPT_REDIR_PROTOCOLS is explicitly set to a bitmask of permitted schemes, libcurl will happily honor any scheme it was compiled with — including file://, dict://, gopher://, sftp://, ldap://, and others. The Grav dispatcher sets none of these options, so the following webhook URL payloads all succeed:
file:///etc/passwd
file:///proc/self/environ
file:///var/www/html/user/config/security.yaml
dict://127.0.0.1:11211/stats ← Memcached info leak
gopher://127.0.0.1:6379/_INFO%0d%0a ← Redis command injection
The file:// scheme returns the raw contents of local files. The response is stored in $response and, depending on the Grav configuration, may surface through API responses, admin UI log entries, or webhook delivery receipts — all of which are accessible to the authenticated attacker.
The dict:// and gopher:// schemes are particularly dangerous because they enable blind SSRF escalation: dict:// speaks the DICT protocol and can extract version banners and statistics from services like Memcached, while gopher:// allows the attacker to craft arbitrary TCP payloads, enabling command injection against Redis, SMTP relays, or other cleartext protocol services on the internal network.
There is no evidence of DNS rebinding mitigations, private-IP blocklists, or response sanitization in the affected versions, meaning the attack is entirely self-contained once the permission is obtained.
Impact
An attacker exploiting CVE-2026-62234 can achieve the following concrete outcomes:
Local file disclosure — Reading /etc/passwd, SSH private keys, application secrets stored in Grav’s flat-file configuration under user/config/, or any file readable by the web server process. In containerised deployments, /proc/self/environ typically exposes injected secrets such as database passwords and API tokens.
Internal service enumeration and exploitation — Using dict:// for banner grabbing and gopher:// for raw protocol interactions, an attacker can probe RFC-1918 address space that is inaccessible from the internet, interact with unauthenticated internal APIs, or issue commands to Redis and Memcached instances that assume network-level isolation as their only defence.
Credential and token harvesting — Cloud-hosted instances on AWS, GCP, or Azure are vulnerable to metadata service exfiltration via http://169.254.169.254/ (though this requires HTTP, the same misconfiguration posture often accompanies the cURL restriction absence). Combined with file:// access to instance credential files, the attack can pivot to full cloud account compromise.
The CVSS 8.1 vector (AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N) correctly captures the network-accessible, low-complexity nature of the exploit and its high confidentiality and integrity impact.
How to Fix It
Upgrade to Grav 2.0.4 or later. The patch enforces a strict protocol allowlist using CURLOPT_PROTOCOLS, restricting dispatch to HTTPS and HTTP only:
// Fixed pattern — Grav 2.0.4+
curl_setopt_array($ch, [
CURLOPT_URL => $webhook->getUrl(),
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
// Allowlist HTTP and HTTPS only; deny file://, gopher://, dict://, etc.
CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS,
CURLOPT_REDIR_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS,
]);
Setting CURLOPT_REDIR_PROTOCOLS is equally important: without it, a redirect from an allowlisted https:// endpoint to file:// would bypass the initial restriction.
Validate the URL scheme at registration time, not only at dispatch time. Reject any webhook URL whose scheme is not http or https before persisting it:
$parsed = parse_url($webhook->getUrl());
$allowed = ['http', 'https'];
if (!isset($parsed['scheme']) || !in_array(strtolower($parsed['scheme']), $allowed, true)) {
throw new \InvalidArgumentException('Webhook URL must use HTTP or HTTPS.');
}
Apply private-IP and metadata-endpoint blocklists as a defence-in-depth layer to prevent SSRF against internal services even over HTTP.
Upgrade via the Grav CLI:
bin/gpm selfupgrade
bin/gpm update
Or update the getgrav/grav Composer dependency directly:
composer require getgrav/grav:"^2.0.4"
Our Take
SSRF via unrestricted cURL protocols is a recurring vulnerability class precisely because PHP’s cURL extension is trusted by developers as a straightforward HTTP client — but it is far more than that. libcurl’s multi-protocol support is a feature for power users and a footgun for everyone else. The correct posture is to configure the minimum required capability: if you need HTTPS, say so explicitly with CURLOPT_PROTOCOLS.
The Grav case also illustrates why permission-gated features require the same scrutiny as unauthenticated endpoints. Enterprises often treat authenticated functionality as implicitly safe, but a single compromised editor account or a misconfigured API token is sufficient to weaponise this vulnerability against infrastructure that was never intended to be reachable from the CMS.
For teams operating Grav in regulated environments, the path from this CVE to cloud credential exfiltration via /proc/self/environ is distressingly short and should be treated as a critical remediation priority regardless of the 8.1 CVSS score.
Detection with SAST
This vulnerability class maps to CWE-918 (Server-Side Request Forgery) and CWE-184 (Incomplete List of Disallowed Inputs). Offensive360’s SAST engine detects it through taint-flow analysis: any user-controlled string that flows into CURLOPT_URL without passing through a scheme validation gate or a CURLOPT_PROTOCOLS restriction is flagged as a high-confidence SSRF sink.
Specific patterns our rules target:
curl_setopt($ch, CURLOPT_URL, $tainted)without a correspondingCURLOPT_PROTOCOLSassignment on the same handle.curl_init($tainted)— passing the URL directly tocurl_init()bypasses options set afterwards ifcurl_exec()is called beforecurl_setopt_array().new \GuzzleHttp\Client(['base_uri' => $tainted])— Guzzle wraps cURL and inherits the same scheme exposure when nocurloption array is supplied.- Missing
parse_url()scheme validation on any value sourced from a database-persisted user configuration field that is later used as a request target.
Organisations running Grav or any PHP application with outbound HTTP dispatch should ensure their SAST pipeline includes dataflow rules from persistence-layer reads back to cURL sinks, not just from direct request parameters.
References
Detect this vulnerability class in your codebase
Offensive360 SAST scans your source code for CVE-2026-62234-class vulnerabilities and thousands of other patterns — across 60+ languages.