Difficulty: 🟡 Practitioner
Goal:
- 🔍 Confirm that
GET /adminis blocked by a front-end system — the plain response reveals it's a proxy-level block, not the app itself - 🧪 Probe: send
GET /withX-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/deleteand add?username=carlosto the real URL line → lab solved!
🧠 Concept Recap
URL-based access control bypass via
X-Original-URLexploits a split-brain architecture. A front-end proxy blocks requests to/adminbased on the URL line in the request. The back-end framework, however, is configured to honour theX-Original-URLheader 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 setsX-Original-URL: /adminwhich 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
- 🌐 Click "Access the lab"
- 🔌 Ensure Burp Suite is running and proxying traffic
- 🖱️ Navigate to
/adminin 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 block4. 🖱️ In Burp → Proxy → HTTP history → find GET /admin → "Send to Repeater"
🔧 Step 2 — Probe: Confirm the Back-End Reads X-Original-URL
- 🖱️ In Burp Repeater, modify the request:
- Change the URL line from
GET /admintoGET / - Add the header:
X-Original-URL: /invalid
GET / HTTP/1.1
Host: YOUR-LAB-ID.web-security-academy.net
X-Original-URL: /invalid2. 🖱️ 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
- 🖱️ In Burp Repeater, change
X-Original-URLto/admin:
GET / HTTP/1.1
Host: YOUR-LAB-ID.web-security-academy.net
X-Original-URL: /admin2. 🖱️ 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/deleteviaX-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- 🖱️ In Burp Repeater, update the request:
GET /?username=carlos HTTP/1.1
Host: YOUR-LAB-ID.web-security-academy.net
X-Original-URL: /admin/delete2. 🖱️ 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 inputsWhy 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 pathThe 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