flawopen.com/ssrf/Php
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.
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_fopenon,file_get_contentsaccepts any stream wrapper:file:///etc/hostname,php://filter/...andhttp://URLs. Thehttpwrapper follows redirects by default (follow_locationis 1), so a public URL can bounce the request inward. FILTER_FLAG_NO_PRIV_RANGE aloneFILTER_FLAG_NO_PRIV_RANGErejects only RFC 1918 and IPv6 unique-local ranges.127.0.0.1,169.254.169.254and0.0.0.0belong to reserved ranges and pass, andgethostbynamereturns a single IPv4 address, ignoring IPv6.cURL protocols and CURLOPT_FOLLOWLOCATION- Many libcurl builds support
file,dictandgopherbesides HTTP, which let a URL talk to non-HTTP services such as Redis. WithCURLOPT_FOLLOWLOCATIONon, a302tohttp://169.254.169.254/is followed after the check passed, and cURL resolves the hostname again anyway (DNS rebinding). CURLOPT_RESOLVE pinningCURLOPT_RESOLVEadds ahost:port:addressentry to cURL's DNS cache, so the connection goes to the address you validated while theHostheader, 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_RANGEaccepts 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
- CWE reference for the weakness described in this guide: CWE-918
- OWASP Top 10:2025 — A01:2025 Broken Access Control
- OWASP API Security Top 10 — API7:2023 Server Side Request Forgery
- D3-OTF Outbound Traffic Filtering — This guide's outbound network restriction limits which destinations the service or host can reach.
Step-by-Step Attack Flow
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.
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.
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.
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
<?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];
}
<?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
- ✓Enable Psalm taint analysis (TaintedSSRF) and review every
file_get_contents,fopenandcurl_initthat receives a user-supplied URL. - ✓Never pass user URLs to stream wrappers; use cURL with
CURLOPT_PROTOCOLSlimited toCURLPROTO_HTTP | CURLPROTO_HTTPS. - ✓Resolve A and AAAA records, require
FILTER_FLAG_GLOBAL_RANGEfor every address and IP literal, and pin the connection withCURLOPT_RESOLVEandCURLOPT_NOPROXY => '*'. - ✓Set
CURLOPT_FOLLOWLOCATIONto false, set connect and total timeouts, and cap the body with a write callback. - ✓Enforce IMDSv2 with a hop limit of 1 and restrict egress with a firewall or security group as a second layer.