
PoC Docker lab: chaining file upload bypass + stored XSS to create admin accounts. Educational resource for pen testers.
A deliberately vulnerable web application demonstrating how a file upload bypass chains with stored XSS to create backdoor admin accounts, even when CSP, CORS, and CSRF protections are in place.
Full Blog Post: KurtiseBear Blog
This is an educational lab for defensive security training. Do not deploy this anywhere publicly accessible.
The application has real security controls:
'self' (but with 'unsafe-inline' and 'unsafe-eval')An attacker with a low-privilege user account chains two vulnerabilities to bypass all of them:
File upload bypass -- The upload form restricts to .pdf via a client-side accept attribute, but the server performs zero file type validation. An attacker uploads a .js file containing JavaScript. The download endpoint serves it from the same origin, so CSP and CORS don't block it.
Stored XSS via message subject -- The messaging feature stores user input without sanitisation. The admin inbox renders the message subject as raw HTML. The XSS payload uses an `` handler to fetch the uploaded script and eval() it. CSP allows this because 'unsafe-inline' and 'unsafe-eval' are permitted.
Missing CSRF on API endpoint -- The user management API (/api/manage-user.php) doesn't validate CSRF tokens, even though form endpoints do. The XSS payload calls this API using the admin's same-origin session. Even if CSRF were present, same-origin JavaScript could read the token from the DOM.
The result: when an admin opens their inbox, the XSS fires, the JavaScript creates a backdoor admin account using the admin's session. Every defence is in place and functioning. The chain works because it never leaves the origin.
docker-compose up -d
Wait 10-15 seconds for MySQL to initialise, then visit http://localhost:8080
| Role | Password | |
|---|---|---|
| Admin | [email protected] | admin |
| User | [email protected] | user |
Navigate to http://localhost:8080 and login with [email protected] / user.
Go to Upload Files. The form says "PDF only" but only enforces this client-side. Either:
accept=".pdf" attribute from the file input, orUpload the provided payload.js (or your own). Note the file ID returned (e.g., 1).
The uploaded file is now served from /api/download.php?file_id=1 on the same origin. CSP won't block fetches to this endpoint because it's 'self'.
Go to Send Message. In the subject field, enter:
r.blob()).then(b=>b.text()).then(eval)">
(Replace 1 with the actual file ID from step 2.)
Put anything in the body. Tick priority if you want it at the top of the inbox. Send.
The onerror handler works because CSP allows 'unsafe-inline'. The eval() works because CSP allows 'unsafe-eval'. The fetch to the download endpoint works because it's same-origin.
Log out. Log in as [email protected] / admin. Go to Inbox.
The message subject renders as raw HTML. The `` tag fails to load, the onerror handler fires, fetches the uploaded payload, and eval() executes it. The payload POSTs to /api/manage-user.php using the admin's session cookie (attached automatically for same-origin requests). No CSRF token needed because the API endpoint doesn't check for one.
Go to Users. You should see a new user: BackdoorAdmin with role admin and email [email protected].
Log out and log in with [email protected] / Compromised1! to confirm.
CSP blocks external scripts
--> But the payload is hosted on the same origin via file upload
--> And unsafe-inline/unsafe-eval allow the onerror handler and eval()
CORS blocks cross-origin requests
--> But every request in the chain is same-origin
CSRF tokens protect form submissions
--> But the API endpoint doesn't validate them
--> And even if it did, same-origin JS can read tokens from the DOM
Session cookies have standard protections
--> But same-origin requests carry them automatically
The defences are all working correctly. They're designed to stop cross-origin attacks. This chain never leaves the origin.
What would actually break this chain:
htmlspecialchars() on all user-controlled output. The inbox renders $row['subject'] raw. This kills the XSS entirely.'unsafe-inline' and 'unsafe-eval'. Use nonces or hashes for legitimate inline scripts. This blocks the onerror handler and eval().docker-compose down -v
This application is deliberately vulnerable. It is designed for educational and defensive security training purposes only. Do not deploy it on any network accessible to untrusted users. Do not use these techniques against systems without explicit written authorisation.