Difficulty: 🟡 Practitioner

Goal:

  • 🔍 Confirm that GET /admin is blocked by a front-end system — the plain response reveals it's a proxy-level block, not the app itself
  • 🧪 Probe: send GET / with X-Original-URL: /invalid — the back-end returns 404, proving it reads the header and not the URL line
  • 🗺️ Switch to X-Original-URL: /admin — the back-end serves the admin panel, bypassing the front-end block
  • 🗑️ Delete carlos: keep X-Original-URL: /admin/delete and add ?username=carlos to the real URL line → lab solved!

🧠 Concept Recap

URL-based access control bypass via X-Original-URL exploits a split-brain architecture. A front-end proxy blocks requests to /admin based on the URL line in the request. The back-end framework, however, is configured to honour the X-Original-URL header and route requests based on that instead of the URL line. The attacker sends a request with a harmless URL line (/) that passes the front-end check, but sets X-Original-URL: /admin which the back-end obeys. The two systems each see a different URL — and that disagreement is the vulnerability.

📊 What Each Layer Sees

Layer Reads Value Decision Front-end proxy URL line GET / ✅ Allowed — / is not blocked Back-end app X-Original-URL header /admin ✅ Serves admin — no role check

The split-brain attack:

Request sent:
    GET / HTTP/1.1
    Host: lab.web-security-academy.net
    X-Original-URL: /admin
  Front-end proxy reads: GET /
    → "/" is not on the blocklist → ALLOWED → forwards to back-end
  Back-end framework reads: X-Original-URL: /admin
    → Ignores URL line entirely
    → Routes request to /admin handler → SERVES ADMIN PANEL
  Result:
    → Front-end was bypassed (it guarded the wrong thing)
    → Back-end served a restricted page with no role check
    → Attacker gets admin panel with no authentication

📊 This Lab vs Labs 1–2 — Blocked vs Unblocked Admin

══════════════════════════════════════════════════════════
  🔬  LABS 1–2 vs LAB 7 (THIS LAB) — KEY DIFFERENCES
══════════════════════════════════════════════════════════

                     Labs 1–2              Lab 7 (this lab)
                     ─────────────────────────────────────────
  Admin path         : ❌ Not blocked —     ✅ Yes — front-end
  blocked?             just hidden          proxy blocks /admin

  Back-end has       : ❌ None              ❌ None — but
  role check?                               front-end was
                                            supposed to guard it

  Why protection     : No check at all      Check is in wrong
  fails                                     layer (front-end,
                                            not back-end)

  Bypass method      : Know the URL         X-Original-URL
                                            header makes
                                            front-end see
                                            a different URL

  Attack tool        : Browser URL bar      Burp Repeater
                                            (must inject a
                                            custom header)

══════════════════════════════════════════════════════════

🛠️ Step-by-Step Attack

🔧 Step 1 — Confirm /admin is Blocked

  1. 🌐 Click "Access the lab"
  2. 🔌 Ensure Burp Suite is running and proxying traffic
  3. 🖱️ Navigate to /admin in your browser:
https://YOUR-LAB-ID.web-security-academy.net/admin
Expected result:
  → You receive a block response — "Access denied" or similar
  → The response is very plain / minimal HTML (not the normal site template)
  → This plain response is a signal: it likely came from the front-end proxy,
    not the back-end application
  → The back-end's own error pages would match the site's style

Key inference:
  → The block is enforced at the front-end (proxy/WAF level)
  → The back-end itself may have no such check
  → If we can make the front-end see a different URL, we may bypass the block

4. 🖱️ In Burp → Proxy → HTTP history → find GET /admin"Send to Repeater"

🔧 Step 2 — Probe: Confirm the Back-End Reads X-Original-URL

  1. 🖱️ In Burp Repeater, modify the request:
  • Change the URL line from GET /admin to GET /
  • Add the header: X-Original-URL: /invalid
GET / HTTP/1.1
Host: YOUR-LAB-ID.web-security-academy.net
X-Original-URL: /invalid

2. 🖱️ Click "Send" — examine the response:

Expected result:
  → Response: "Not Found" (404) for path /invalid

Why this is the proof:
  → We requested GET / - that path exists and should return the home page
  → But the response is 404 for /invalid
  → This means the back-end IGNORED the URL line (GET /)
    and instead routed based on X-Original-URL: /invalid
  → /invalid doesn't exist → 404
  → If the back-end had ignored the header:
    → GET / would have returned the home page (200), not 404

This single test proves:
  ✅ The back-end framework reads X-Original-URL
  ✅ It uses X-Original-URL to route the request, overriding the URL line
  ✅ The front-end only blocked based on the URL line
  ✅ We can now specify any path in X-Original-URL and the back-end will route to it

🔧 Step 3 — Access the Admin Panel

  1. 🖱️ In Burp Repeater, change X-Original-URL to /admin:
GET / HTTP/1.1
Host: YOUR-LAB-ID.web-security-academy.net
X-Original-URL: /admin

2. 🖱️ Click "Send"

Expected result:
  → Response: 200 OK — the admin panel HTML is returned
  → Front-end saw GET / → allowed it through
  → Back-end read X-Original-URL: /admin → routed to admin handler
  → Admin panel served with no role check

You can also render this in your browser:
  → In Repeater → right-click response → "Show response in browser"
  → Copy the URL → paste into browser → admin panel renders

🔧 Step 4 — Delete Carlos

The delete action requires two things simultaneously:

  • The path /admin/delete via X-Original-URL
  • The query parameter ?username=carlos — but this must go on the real URL line, not the header
Why the query string goes on the URL line (not the header):
  → X-Original-URL tells the back-end WHICH ROUTE to serve
  → Query parameters (?username=carlos) are read separately from the route
  → The back-end reads query params from the actual URL line
  → So: URL line carries the params, X-Original-URL carries the route path

Final request structure:
  → URL line:          GET /?username=carlos    ← params here
  → X-Original-URL:   /admin/delete            ← route here
  1. 🖱️ In Burp Repeater, update the request:
GET /?username=carlos HTTP/1.1
Host: YOUR-LAB-ID.web-security-academy.net
X-Original-URL: /admin/delete

2. 🖱️ Click "Send"

Expected result:
  → Response: 302 redirect or confirmation page
  → carlos is deleted from the system ✅
  → Lab solved! 🎉

What happened end-to-end:
  → Front-end saw: GET /?username=carlos → not /admin → ALLOWED through
  → Back-end read: X-Original-URL: /admin/delete → routed to delete handler
  → Back-end read: ?username=carlos from URL line → identified target
  → Delete action executed → carlos removed

🎉 Lab Solved!

✅ Confirmed GET /admin is blocked at front-end (plain response = proxy block)
✅ Probed with X-Original-URL: /invalid → got 404 → back-end reads the header
✅ Set X-Original-URL: /admin → admin panel bypassed front-end block
✅ Sent GET /?username=carlos + X-Original-URL: /admin/delete → carlos deleted
✅ Lab complete!

🔗 Complete Attack Chain

1. GET /admin → blocked (plain response = front-end proxy block)

2. GET / + X-Original-URL: /invalid
   → 404 for /invalid (proves back-end routes on header, not URL line)

3. GET / + X-Original-URL: /admin
   → Admin panel returned (front-end bypassed)

4. GET /?username=carlos + X-Original-URL: /admin/delete
   → carlos deleted → lab solved

🧠 Deep Understanding

How X-Original-URL works — the framework side

Some back-end frameworks (notably Spring, certain PHP setups, and others behind
reverse proxies) are designed to read X-Original-URL or X-Rewrite-URL.

The intended use case is legitimate:
  → A reverse proxy (nginx/Apache) rewrites the URL for internal routing
  → It sets X-Original-URL to preserve the original request path for logging
  → The back-end app reads this header to know what the user originally requested

Example (intended):
  nginx rewrites /app/profile → /index.php?route=profile
  nginx sets:     X-Original-URL: /app/profile
  Back-end reads: X-Original-URL to know the "original" URL for routing

The vulnerability:
  → If the back-end framework reads X-Original-URL for ROUTING (not just logging),
    and the header is accepted from untrusted clients,
    then any client can set any path they want
  → The front-end proxy only sees the URL line - it never looks at X-Original-URL
  → The two systems are making decisions based on different inputs

Why front-end access control is always fragile

The architecture in this lab:
  [Client] → [Front-end proxy: blocks /admin] → [Back-end: no /admin check]

The assumption:
  → "The front-end will always block /admin before it reaches the back-end"
  → "So the back-end doesn't need its own check"

Why this assumption fails:
  1. X-Original-URL (this lab) - header splits front/back routing
  2. HTTP method override - POST with X-HTTP-Method-Override: GET bypasses method-based rules
  3. Case sensitivity - /Admin vs /admin may pass case-sensitive blocklists
  4. URL encoding - /%61dmin vs /admin bypasses string matching
  5. Path normalization - /admin/../admin or /./admin
  6. Direct back-end access - if the back-end is ever exposed without the proxy

The lesson:
  → Access control must be enforced by the back-end itself, on every request
  → Front-end blocks are a hardening layer, not the primary defence
  → Never assume the front-end will always be in the request path

The probe step — why it matters

Step 2 (X-Original-URL: /invalid → 404) is not just confirming the technique works.
It's a disciplined proof-of-concept before escalating to the actual attack:

If we skipped straight to X-Original-URL: /admin and got 200:
    → We'd assume it worked - but maybe /admin just returned 200 because
      the block was removed, or the proxy was different, or we were lucky
  
   By probing with /invalid first:
    → We get a 404 specifically for /invalid (a path that shouldn't exist)
    → This unambiguously proves the back-end is routing on X-Original-URL
    → Now we know exactly WHY the bypass will work, not just that it works

Good security testing methodology:
  → Understand the mechanism before exploiting it
  → The probe step separates "it happened to work" from "I know why it works"

🐛 Troubleshooting

Problem: X-Original-URL: /admin still returns a block page
→ The back-end may not support X-Original-URL — try X-Rewrite-URL instead
→ Check Step 2 first: does X-Original-URL: /invalid give 404 or the home page?
  → If home page → back-end is ignoring the header → different bypass needed
  → If 404 → back-end reads the header → proceed with /admin

Problem: Step 4 (delete) returns 400 or "missing parameter"
→ Ensure ?username=carlos is on the URL LINE, not inside X-Original-URL
→ Correct:   GET /?username=carlos   +   X-Original-URL: /admin/delete
→ Incorrect: GET /                   +   X-Original-URL: /admin/delete?username=carlos
→ The back-end reads query params from the URL line, routes from the header

Problem: Step 4 returns 302 but carlos still appears in the admin panel
→ The redirect means the delete executed - follow the redirect to confirm
→ In Repeater: check "Follow redirects" or manually visit /admin again
→ Refresh the admin panel view to see the updated user list

Problem: Burp Repeater drops the X-Original-URL header
→ Ensure you typed the header name exactly: X-Original-URL (capital X, capital O, capital U)
→ Some Burp versions may strip unknown headers - check Inspector tab to verify it's present
→ Paste the full header manually: X-Original-URL: /admin

💬 In One Line

🔀 The front-end proxy blocked GET /admin by URL line — but the back-end framework routed on X-Original-URL instead — so sending GET / with X-Original-URL: /admin gave the front-end a harmless URL and the back-end an admin route. That's URL-based access control bypass via header override — two layers, two different URLs, zero coordination.

🔒 How to Fix It

// Priority 1 — Strip or reject X-Original-URL from client requests at the front-end
// The header should only ever be set by your own internal infrastructure

// nginx - strip client-supplied X-Original-URL before forwarding to back-end:
proxy_set_header X-Original-URL "";   // ← overwrite with empty - client value discarded
// Apache mod_proxy:
RequestHeader unset X-Original-URL   // ← remove the header from forwarded requests
// Result: back-end only ever sees X-Original-URL if nginx itself set it
// Client-supplied values are always gone before they reach the back-end
// Priority 2 — Back-end must have its own access control, independent of the front-end
// Never rely on the proxy being in the request path

// ❌ BAD - back-end has no /admin check (trusts front-end to block it):
app.get('/admin', (req, res) => {
  res.render('admin');  // no role check - assumes proxy blocked non-admins
});
// ✅ GOOD - back-end enforces role check regardless of how the request arrived:
app.get('/admin', requireLogin, (req, res) => {
  if (req.session.role !== 'admin') {
    return res.status(403).send('Forbidden');
  }
  res.render('admin');
});
// Now: even if X-Original-URL bypass routes to /admin,
// the back-end rejects the request because the session has no admin role
// Priority 3 — If X-Original-URL must be used internally, validate its source
// Only accept the header if it came from a trusted internal IP (the proxy)
app.use((req, res, next) => {
  const internalProxyIP = '10.0.0.1';  // your proxy's internal IP
  if (req.headers['x-original-url']) {
    if (req.ip !== internalProxyIP) {
      // Header present but request didn't come from trusted proxy
      delete req.headers['x-original-url'];  // discard it
    }
  }
  next();
});
// Better: configure the proxy to strip it (Priority 1) - don't rely on app-level filtering
// Priority 4 — Audit all headers your back-end framework reads for routing
// Frameworks that support X-Original-URL or X-Rewrite-URL may do so silently
// Check your framework docs for:
//   → X-Original-URL
//   → X-Rewrite-URL
//   → X-Forwarded-Path
//   → X-Original-URI
// Disable or restrict these features if you don't need them:
// Spring Boot: configure to not trust forwarded headers unless from known proxies
// server.forward-headers-strategy=NATIVE or FRAMEWORK (check which trusts clients)

👏 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