FlawOpen

flawopen.com/ssrf/Php

● CWE-918 · High
FlawOpen Security Research

Server-Side Request Forgery (SSRF) in PHP (cURL, filter_var)

file_get_contents($url) lets any request make your PHP app read file://, php:// and internal http:// URLs, including the cloud metadata service at 169.254.169.254 (CWE-918), and a gethostbyname() check with FILTER_FLAG_NO_PRIV_RANGE misses loopback and link-local addresses, loses to DNS rebinding and is bypassed when cURL follows redirects. How to validate every resolved address with FILTER_FLAG_GLOBAL_RANGE and pin cURL to it with CURLOPT_RESOLVE.

💡 Plain English Explainer (ELI5)

Imagine a hotel desk that accepts route slips for more than street deliveries: one slip can open the records room, another can pass through an internal document-processing hatch, and a web parcel can be redirected after the clerk checks its first address. PHP URL wrappers behave similarly: file_get_contents() can accept file:// and php://filter as well as http://, while cURL may support extra protocols and can resolve a hostname again. The defensive path accepts HTTP(S) only, checks every address with FILTER_FLAG_GLOBAL_RANGE, pins the chosen public IP with CURLOPT_RESOLVE, and disables CURLOPT_FOLLOWLOCATION so a redirect cannot change the destination.

Core Concepts & Subsystem Terms

file_get_contents() on a URL
With allow_url_fopen on, file_get_contents accepts any stream wrapper: file:///etc/hostname, php://filter/... and http:// URLs. The http wrapper follows redirects by default (follow_location is 1), so a public URL can bounce the request inward.
FILTER_FLAG_NO_PRIV_RANGE alone
FILTER_FLAG_NO_PRIV_RANGE rejects only RFC 1918 and IPv6 unique-local ranges. 127.0.0.1, 169.254.169.254 and 0.0.0.0 belong to reserved ranges and pass, and gethostbyname returns a single IPv4 address, ignoring IPv6.
cURL protocols and CURLOPT_FOLLOWLOCATION
Many libcurl builds support file, dict and gopher besides HTTP, which let a URL talk to non-HTTP services such as Redis. With CURLOPT_FOLLOWLOCATION on, a 302 to http://169.254.169.254/ is followed after the check passed, and cURL resolves the hostname again anyway (DNS rebinding).
CURLOPT_RESOLVE pinning
CURLOPT_RESOLVE adds a host:port:address entry to cURL's DNS cache, so the connection goes to the address you validated while the Host header, SNI and certificate check still use the hostname. No second lookup happens.
FILTER_FLAG_GLOBAL_RANGE (PHP 8.2)
Since PHP 8.2, FILTER_FLAG_GLOBAL_RANGE accepts only addresses that are globally reachable according to the IANA special-purpose registries, rejecting private, loopback, link-local, CGNAT and other reserved IPv4 and IPv6 ranges in one flag.

Framework mappings

Step-by-Step Attack Flow

Step 1

A user supplies a URL

A link-preview or webhook handler takes url from a JSON body and passes it to file_get_contents or curl_init.

Step 2

The app connects to an internal address

The URL names 127.0.0.1, a private host, 169.254.169.254, a file:// or gopher:// target, a hostname that rebinds, or a public page that redirects inward. The benign test value http://127.0.0.1:8080/health illustrates the case.

Step 3

An internal endpoint may be reachable

PHP's stream or cURL client sends requests from the app server's network position. Internal panels, HTTP cache services, or metadata endpoints may be reachable, but network rules or authentication can still reject them.

Step 4

The attacker reads secrets or pivots

The response path creates the disclosure: the preview can return file_get_contents bytes, while the cURL webhook returns its status. Return only the data the feature needs, and keep internal credentials and response bodies out of caller-controlled fetch results.

Source Code: Flaw vs. Secure Implementation

UNPATCHED FLAW
<?php
// fetch.php: both functions let the caller choose the destination
declare(strict_types=1);

// any wrapper and any host, and the http wrapper follows redirects
function preview(array $input): string
{
    return substr((string) file_get_contents($input['url']), 0, 5000);
}

// NO_PRIV_RANGE lets 127.0.0.1 through; cURL resolves again and follows redirects
function testWebhook(array $input): array
{
    $ip = gethostbyname((string) parse_url($input['url'], PHP_URL_HOST));
    if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE) === false) {
        throw new InvalidArgumentException('blocked destination');
    }
    $ch = curl_init($input['url']);
    curl_setopt_array($ch, [
        CURLOPT_POSTFIELDS => '{"ping":true}',
        CURLOPT_FOLLOWLOCATION => true,
        CURLOPT_RETURNTRANSFER => true,
    ]);
    curl_exec($ch);
    $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    curl_close($ch);
    return ['status' => $status];
}
HARDENED SECURE PATCH
<?php
// fetch.php: validate every address, then pin cURL to it
declare(strict_types=1);

// GLOBAL_RANGE rejects every private, loopback, link-local and reserved range
function publicAddresses(string $host): array
{
    $host = trim($host, '[]');
    if (filter_var($host, FILTER_VALIDATE_IP) !== false) {
        $ips = [$host];
    } else {
        $records = dns_get_record($host, DNS_A | DNS_AAAA) ?: [];
        $ips = array_map(fn (array $r): string => $r['ip'] ?? $r['ipv6'], $records);
    }
    if ($ips === []) {
        throw new InvalidArgumentException('host does not resolve');
    }
    foreach ($ips as $ip) {
        if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_GLOBAL_RANGE) === false) {
            throw new InvalidArgumentException('destination is not a public address');
        }
    }
    return $ips;
}

// HTTP(S) only, no redirects, no proxy, pinned address, capped body
function fetchPublic(string $url, ?string $jsonBody = null): array
{
    $parts = parse_url($url);
    $scheme = strtolower($parts['scheme'] ?? '');
    if (!in_array($scheme, ['http', 'https'], true) || empty($parts['host'])) {
        throw new InvalidArgumentException('only http and https URLs are allowed');
    }
    $host = trim($parts['host'], '[]');
    $port = $parts['port'] ?? ($scheme === 'https' ? 443 : 80);
    $ip = publicAddresses($host)[0];
    $pinned = str_contains($ip, ':') ? "[$ip]" : $ip;
    $body = '';
    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_RESOLVE => ["$host:$port:$pinned"],
        CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS,
        CURLOPT_FOLLOWLOCATION => false,
        CURLOPT_NOPROXY => '*',
        CURLOPT_CONNECTTIMEOUT => 3,
        CURLOPT_TIMEOUT => 10,
        CURLOPT_WRITEFUNCTION => function ($ch, string $chunk) use (&$body): int {
            $body .= $chunk;
            return strlen($body) > 1_000_000 ? 0 : strlen($chunk);
        },
    ]);
    if ($jsonBody !== null) {
        curl_setopt_array($ch, [
            CURLOPT_POSTFIELDS => $jsonBody,
            CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
        ]);
    }
    curl_exec($ch);
    $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    curl_close($ch);
    return ['status' => $status, 'body' => $body];
}

// both handlers go through fetchPublic
function preview(array $input): string
{
    return substr(fetchPublic((string) ($input['url'] ?? ''))['body'], 0, 5000);
}

function testWebhook(array $input): array
{
    return ['status' => fetchPublic((string) ($input['url'] ?? ''), '{"ping":true}')['status']];
}

Engineering & System Hardening Checklist

Sources