Difficulty: 🟡 Practitioner

Goal:

  • 🔍 Log in as administrator:admin → promote carlos via the admin panel → capture POST /admin-roles in Burp Repeater
  • 🍪 Log in as wiener:peter in a private window → copy wiener's session cookie into the captured request → confirm POST is rejected with "Unauthorized"
  • 🔀 Change method to POSTX → response changes to "missing parameter" (proving the role check only fires on POST)
  • ⚡ Right-click → "Change request method" → converts to GET with params in the query string → change username=carlos to username=wiener → send → wiener is promoted to admin → lab solved!

🧠 Concept Recap

Method-based access control bypass occurs when a role check is tied to a specific HTTP method rather than to the action itself. The developer added an admin check that fires when the endpoint receives a POST - but forgot to enforce the same check for GET (or any other method). Since GET and POST often reach the same back-end handler and the same business logic, a non-admin can perform the privileged action simply by sending it as a GET. The method is irrelevant to what the code actually does — it only gates who can do it.

📊 What Each Method Triggers

══════════════════════════════════════════════════════════
  🔬  METHOD SWITCHING — WHAT HAPPENS EACH WAY
══════════════════════════════════════════════════════════

  Request    Session   Role Check    Action
  Method               Fires?        Executes?
  ──────────────────────────────────────────────────────
  POST       admin     ✅ Yes —      ✅ Yes
  (normal               passes
  admin
  promote)

  POST       wiener    ✅ Yes —      ❌ Blocked
                        fails → 401

  POSTX      wiener    ❌ No check   ⚠️  "missing
                        at all        parameter"

  GET        wiener    ❌ No check   ✅ Action
                        at all        executes

══════════════════════════════════════════════════════════
The flaw in one line:
  → Role check: if (method === 'POST' && !isAdmin) → reject
  → GET handler: processes the same upgrade logic with no role check at all
  → Changing POST → GET sidesteps the check entirely

Why POSTX matters as a probe:
  → POSTX is not a standard HTTP method
  → The role check rule says "if POST → check role"
  → POSTX is not POST → role check doesn't fire
  → Response changes from "Unauthorized" to "missing parameter"
  → This change proves the role check is method-specific, not action-specific
  → Now we know any non-POST method will bypass it - GET is the cleanest choice

The full attack flow:

Phase 1 — Recon as admin:
  Log in as administrator:admin
  Admin panel → Upgrade user → promote carlos
  Capture: POST /admin-roles?username=carlos&action=upgrade (with admin session)
  Send to Burp Repeater ✅

Phase 2 - Confirm the block:
  Log in as wiener:peter (private window) → copy wiener's session cookie
  Paste wiener's cookie into the captured request → Send
  Response: "Unauthorized" ✅ (POST is protected)

Phase 3 - Probe the method check:
  Change method: POST → POSTX → Send
  Response: "missing parameter" (not "Unauthorized")
  ✅ Proves: role check only fires on POST

Phase 4 - Bypass:
  Right-click → "Change request method" → converts to GET
  Burp moves params to query string: GET /admin-roles?username=carlos&action=upgrade
  Change username=carlos → username=wiener → Send
  Response: 200 / 302 - wiener is now admin ✅
 
 Lab solved! 🎉

🛠️ Step-by-Step Attack

🔧 Step 1 — Log In as Admin and Capture the Promote Request

  1. 🌐 Click "Access the lab"
  2. 🔌 Ensure Burp Suite is running and proxying traffic
  3. 🖱️ Log in as administrator / admin
  4. 🖱️ Navigate to the admin panel (it should be visible in the nav as you're logged in as admin)
  5. 🖱️ Click "Upgrade user" next to carlos — this triggers the promote action
The Upgrade button sends a POST request to the admin endpoint.
We're doing this as admin to capture a WORKING request structure —
we need the exact URL, parameters, and format before we attempt the bypass.

6. 🖱️ In Burp → Proxy → HTTP history → find POST /admin-roles with username=carlos

7. 🖱️ Right-click → "Send to Repeater"

The captured request looks like:

POST /admin-roles?username=carlos&action=upgrade HTTP/1.1
  Host: YOUR-LAB-ID.web-security-academy.net
  Cookie: session=ADMIN-SESSION-COOKIE
  Content-Length: 0
  (or the username may be in the body - either way, note the structure)

Keep this tab open in Repeater.
Do NOT close it - we'll modify it in subsequent steps.

🔧 Step 2 — Get Wiener's Session Cookie

  1. 🖱️ Open a private / incognito browser window — this keeps sessions separate
  2. 🖱️ Log in as wiener / peter in the incognito window
  3. 🖱️ In Burp → Proxy → HTTP history → find any recent request from the incognito session → look at the Cookie header:
Cookie: session=WIENER-SESSION-COOKIE-VALUE

4. 🖱️ Copy wiener's session cookie value

Why a private window?
  → Your main browser is still logged in as admin
  → A private window creates a separate session
  → This gives us wiener's cookie without logging out of admin
  → We need both sessions simultaneously:
    - Admin session: to have a working captured request
    - Wiener's session: to replay that request as a non-admin

🔧 Step 3 — Confirm POST is Blocked for Non-Admins

  1. 🖱️ In Burp Repeater, in the POST /admin-roles request:
  • Find the Cookie: header
  • Replace the admin session value with wiener's session cookie:
POST /admin-roles?username=carlos&action=upgrade HTTP/1.1
Host: YOUR-LAB-ID.web-security-academy.net
Cookie: session=WIENER-SESSION-COOKIE

2. 🖱️ Click "Send"

Expected response:
  → "Unauthorized" (401 or similar)
  → The POST endpoint checks the role → wiener is not admin → rejected ✅

This confirms:
  → The POST endpoint HAS an access control check
  → The check correctly rejects non-admins for POST requests
  → But the question is: does the check apply to ALL methods, or only POST?

🔧 Step 4 — Probe: Change Method to POSTX

  1. 🖱️ In Burp Repeater, manually edit the first line of the request:
  • Change POST to POSTX:
POSTX /admin-roles?username=carlos&action=upgrade HTTP/1.1
Host: YOUR-LAB-ID.web-security-academy.net
Cookie: session=WIENER-SESSION-COOKIE

2. 🖱️ Click "Send"

Expected response:
  → "missing parameter" (or similar error — NOT "Unauthorized")

Why this is significant:
  → The response changed from "Unauthorized" to "missing parameter"
  → "Unauthorized" = role check fired and rejected wiener
  → "missing parameter" = role check did NOT fire - a different handler ran
  → The role check rule is: if (method === 'POST') → check role
  → POSTX ≠ POST → the rule doesn't match → check is skipped
  → The server reached actual business logic but failed on input validation
  → This proves conclusively: the role check is method-specific
  → Any non-POST method bypasses it
  → GET is the standard alternative - parameters move to the query string

🔧 Step 5 — Convert to GET and Promote Wiener

  1. 🖱️ Right-click anywhere in the Repeater request panel → "Change request method"
What Burp does automatically:
  → Changes POST → GET
  → Moves any body parameters to the query string
  → Result:

GET /admin-roles?username=carlos&action=upgrade HTTP/1.1
  Host: YOUR-LAB-ID.web-security-academy.net
  Cookie: session=WIENER-SESSION-COOKIE

2. 🖱️ Change username=carlos to username=wiener in the query string:

GET /admin-roles?username=wiener&action=upgrade HTTP/1.1
Host: YOUR-LAB-ID.web-security-academy.net
Cookie: session=WIENER-SESSION-COOKIE

3. 🖱️ Click "Send"

Expected response:
  → 200 OK or 302 redirect — no "Unauthorized"
  → The upgrade action executes for wiener
  → The role check never fired — it only guards POST, not GET

What just happened:
  → The GET handler for /admin-roles has no role check
  → It ran the same upgrade business logic as POST would
  → wiener's account was promoted to admin in the database

🔧 Step 6 — Verify Admin Access

  1. 🖱️ In the incognito window (logged in as wiener), navigate to the admin panel:
https://YOUR-LAB-ID.web-security-academy.net/admin
Expected result:
  → Admin panel loads for wiener ✅
  → wiener is now an administrator
  → Lab solved! 🎉

🎉 Lab Solved!

✅ Captured POST /admin-roles as admin
✅ Confirmed POST is blocked for wiener (Unauthorized)
✅ Changed to POSTX → response changed to "missing parameter" (role check bypassed)
✅ Changed to GET + username=wiener → upgrade executed — wiener is now admin
✅ Lab complete!

🔗 Complete Attack Chain

1. Log in as admin → promote carlos → capture POST /admin-roles → Repeater

2. Swap to wiener's session cookie → POST still blocked → "Unauthorized" ✅

3. Change POST → POSTX → "missing parameter" (proves check is method-specific)

4. Right-click → "Change request method" → GET

5. Change username=carlos → username=wiener → Send → 200 OK → wiener promoted

6. Verify: /admin loads for wiener → lab solved

🧠 Deep Understanding

Why does the role check only fire on POST?

The developer probably wrote something like this:

// Middleware / route guard
  app.post('/admin-roles', requireAdmin, handleUpgrade);
  app.get('/admin-roles', handleUpgrade);   // ← no requireAdmin here!

Or using a framework with method-based routing:
  @PostMapping("/admin-roles")
  @PreAuthorize("hasRole('ADMIN')")   // ← role check tied to POST mapping
  public Response upgradeUser(String username) { ... }
  @GetMapping("/admin-roles")
  public Response upgradeUser(String username) { ... }  // ← no @PreAuthorize

The developer secured the POST route and forgot the GET route.
Both routes call the same handleUpgrade / upgradeUser business logic.
The business logic doesn't care about the caller's role - it just executes.

The POSTX probe — why it's the key diagnostic step

The POSTX step is not just curiosity — it's a precise diagnostic:

POST  + wiener → "Unauthorized"    → role check FIRES for POST
  POSTX + wiener → "missing param"   → role check DOES NOT FIRE for POSTX
  GET   + wiener → 200 (executes)    → role check DOES NOT FIRE for GET

The change from "Unauthorized" to "missing parameter" is the signal:
  → "Unauthorized" = access control rejected the request
  → "missing parameter" = access control was SKIPPED, actual handler ran
  → The handler ran but complained about input - wiener reached the business logic
Without the POSTX probe, you could go straight to GET and it would still work.

But the probe gives you evidence of WHY it works - valuable for understanding
and for reporting: "The role check is only bound to the POST method handler."

Method override as an alternative bypass

Some frameworks also honour method override headers:
  X-HTTP-Method-Override: GET
  X-Method-Override: GET
  _method=GET (in POST body)

These let a POST request be treated as a GET by the framework:
  POST /admin-roles HTTP/1.1
  X-HTTP-Method-Override: GET
  Cookie: session=wiener-session
  → Framework sees: treat this as GET
  → GET handler runs → no role check → upgrade executes

This is relevant when:
  → The server only supports POST (e.g., from a browser form)
  → But the back-end framework supports method override headers
  → Some WAFs block GET to sensitive endpoints but allow POST with override header

In this lab, plain GET works - no override header needed.

Lab 7 vs Lab 8 — Two platform-level bypass techniques

Lab 7 (X-Original-URL):
  → Front-end sees a different URL than the back-end
  → Bypass: deceive the front-end about which URL is being accessed
  → Attack layer: routing / URL dispatch

Lab 8 (method change):
  → Role check is bound to one HTTP method, not the action
  → Bypass: use a different method to reach the same handler without the check
  → Attack layer: method-based middleware / route guards

Both share the same root cause:
  → Access control is implemented at the wrong level of abstraction
  → It should be on the ACTION (the business logic function itself)
  → Not on the URL pattern (Lab 7) or the HTTP method (Lab 8)

🐛 Troubleshooting

Problem: POSTX returns "Unauthorized" (same as POST) — not "missing parameter"
→ This framework treats unknown methods the same as POST for routing
→ Skip straight to GET — the POSTX probe gave you the same result as POST
→ Change method to GET and try — the role check may still only guard POST

Problem: GET returns "Unauthorized" too - not 200
→ The GET handler also has a role check - method-based bypass won't work here
→ Try: X-HTTP-Method-Override: GET on a POST request
→ Try: HEAD, PUT, PATCH methods
→ The vulnerability may require a different bypass technique

Problem: Can't find wiener's session cookie in Proxy history
→ Ensure Burp is intercepting the incognito window (check proxy settings)
→ Navigate to /my-account in the incognito window to generate a request
→ The session cookie is in the Cookie: header of any request from that window

Problem: After sending GET with username=wiener, admin panel doesn't load for wiener
→ The request may have used the wrong session - confirm Cookie: is wiener's session
→ Hard-refresh the incognito window (Ctrl+Shift+R) to clear any cached auth state
→ Check: the GET response should be 200 or 302 (not 401/403)

Problem: Burp's "Change request method" doesn't appear in right-click menu
→ Right-click inside the request editor panel (not the tab or header area)
→ Alternatively: manually type GET and move params from body to ?param=value in URL

💬 In One Line

🔀 The admin role check only guarded POST requests — so changing the method to GET reached the same upgrade handler with no check at all, promoting wiener to admin with a non-admin session. That's Method-Based Access Control Bypass — securing the method, not the action.

🔒 How to Fix It

// Priority 1 — Bind the role check to the ACTION, not the method
// Never assume the method is a reliable security boundary
// ❌ BAD - role check only on POST route:
app.post('/admin-roles', requireAdmin, handleUpgrade);
app.get('/admin-roles', handleUpgrade);   // ← no check!
// ✅ GOOD - role check on every route that reaches the action:
app.post('/admin-roles', requireAdmin, handleUpgrade);
app.get('/admin-roles', requireAdmin, handleUpgrade);
// Or better - put the role check INSIDE the handler itself:
function handleUpgrade(req, res) {
  if (!req.session || req.session.role !== 'admin') {
    return res.status(403).send('Forbidden');
  }
  // proceed with upgrade...
}
// Now it doesn't matter what method is used - the handler always checks
// Priority 2 — Restrict which HTTP methods the endpoint accepts at all
// If the endpoint only needs POST, explicitly reject everything else
app.all('/admin-roles', (req, res, next) => {
  if (req.method !== 'POST') {
    return res.status(405).send('Method Not Allowed');
  }
  next();
});
app.post('/admin-roles', requireAdmin, handleUpgrade);
// Or in Express:
// app.use('/admin-roles', methodNotAllowed(['POST']));
// Any GET/POSTX/PUT/etc. → 405 immediately, never reaches the handler
// Priority 3 — Reject method override headers from untrusted clients
// Prevent X-HTTP-Method-Override from being used to tunnel methods
app.use((req, res, next) => {
  // If client sends a method override header, ignore it
  delete req.headers['x-http-method-override'];
  delete req.headers['x-method-override'];
  // Don't use req.body._method to override the HTTP method
  next();
});
// Framework-level: disable method override middleware if your framework supports it
// Express: don't use method-override npm package unless intentionally needed
// Priority 4 — In frameworks with annotation-based routing, secure all method variants
// Java Spring - ❌ BAD: only POST is secured:
@PostMapping("/admin-roles")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity upgrade(@RequestParam String username) { ... }
@GetMapping("/admin-roles")              // ← no @PreAuthorize
public ResponseEntity upgrade(@RequestParam String username) { ... }
// ✅ GOOD: Apply security at the method level and lock down all HTTP methods:
@RequestMapping(value = "/admin-roles", method = {RequestMethod.POST})
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity upgrade(@RequestParam String username) { ... }
// Or: use a class-level @PreAuthorize on the AdminController class itself

👏 If this helped you — clap it up (you can clap up to 50 times!)

🔔 Follow for more writeups — dropping soon

🔗 Share with your pentest team

💬 Drop a comment