Neven Marić
FortID 2026

IntroFortID 2026

Baby Web

A button dodges the cursor before revealing the flag, but the click handler decodes a string that was baked into the page the whole time, so the decode runs fine outside the browser.

  • web
  • client-side-secrets
  • javascript
  • base64
  • static-analysis

What this one teaches

  • A secret shipped in client-side code is never hidden, no matter how it is dressed up: the browser has to be able to read it, so you can too.
  • A UI trick, such as a button that moves away from the cursor, governs what the interface offers and never what the user can reach.
  • Base64 is an encoding, not encryption, and recognising its shape (an = tail, a length divisible by 4, the A-Za-z0-9+/ alphabet) tells you which direction to decode in.
  • Reading a page's own script is usually faster than beating its UI, and it also tells you whether the answer is in the page or behind a request.

Hints

Stuck? Open one hint, then go back and try again before reading the next.

Hint 1 of 1Show

Nothing on this page talks to a server. So where does the text in the flag element come from?

Full solution below. Spoilers ahead.

Task description

The flag is only one click away… if you can catch it!

What the page actually ships

The handout is one file, index.html, and the page it draws is a card reading “Catch me if you can!” with a cyan button labelled “Show me the flag”. The button is position: fixed with a 120ms CSS transition on left and top, so when it moves it glides rather than jumps, which sells the idea that catching it is the task.

Before trying to catch anything, it is worth asking where the flag could possibly come from. There is no fetch, no XMLHttpRequest, no form, no <img> pointing at a backend, and no server in the handout at all. A page that never talks to anything can only display what it already contains. That reframes the task from “click a moving target” to “find the string in the file”, and the whole page is 180 lines, most of it CSS.

Everything that behaves lives in one inline <script> at the bottom.

Reading the click handler

Two listeners are attached to the button, and only one of them matters:

const button = document.querySelector("#runaway");
const output = document.querySelector("#flag");

function runAway() {
  const padding = 16;
  const maxX = Math.max(padding, window.innerWidth - button.offsetWidth - padding);
  const maxY = Math.max(padding, window.innerHeight - button.offsetHeight - padding);

  button.style.transform = "none";
  button.style.left = `${padding + Math.random() * (maxX - padding)}px`;
  button.style.top = `${padding + Math.random() * (maxY - padding)}px`;
}

button.addEventListener("mouseenter", runAway);

button.addEventListener("click", () => {
  const scrambled = [
    "=0XNx81Nx81auFDa38Vdwk3",
    "X1QzXkJHNo91N0g2NfdDMu9V",
    "Nx8lYzc3eElEdy9mR"
  ].join("");

  output.textContent = atob([...scrambled].reverse().join(""));
  output.classList.add("success");
  button.textContent = "You caught me!";
  button.removeEventListener("mouseenter", runAway);
  button.disabled = true;
});

runAway fires on mouseenter and assigns a random left and top inside the viewport. That is the entire “catch me” mechanic, and it does nothing except move an element.

The click handler never checks a cookie, a score, a timer, or how the click was produced. It joins three hardcoded string fragments, reverses the result, hands it to atob and writes the output into the #flag div. The flag is a constant sitting in the file, and the click is only the moment it gets rendered.

Reading the obfuscation backwards

The three fragments concatenate to this, which is 64 characters long:

=0XNx81Nx81auFDa38Vdwk3X1QzXkJHNo91N0g2NfdDMu9VNx8lYzc3eElEdy9mR

That is not valid base64 as written, and the giveaway is the = at the front. Base64 pads with = at the end, never the start, so this string is reversed. The handler’s own [...scrambled].reverse().join("") confirms it: spread the string into an array of characters, reverse the array, join it back. Undo that and the string becomes well-formed base64, which atob (the browser’s built-in base64 decoder) turns into text.

Why base64 is an encoding and not protection

Base64 exists to carry arbitrary bytes through channels that only safely handle text, such as a JSON field, a data URI or an email body. It maps every 3 bytes of input onto 4 characters drawn from a fixed 64-character alphabet (A-Za-z0-9+/), padding the final group with = so the length is always divisible by 4.

That mapping is public and reversible by design, with no key involved. Base64 hides a value about as well as writing it backwards does, which is to say it costs a reader a few seconds. Reversing the string first adds a second public transform, so the whole cost to an attacker is working out the order to undo two steps.

The practical tell is the shape. A length divisible by 4, characters confined to that alphabet, and one or two trailing = means you are looking at base64 and should decode rather than attack it.

Reproducing the decode offline

Running the page’s own logic outside the browser, with the real fragments and the same reverse-then-decode order:

import base64, hashlib

frags = [
    "=0XNx81Nx81auFDa38Vdwk3",
    "X1QzXkJHNo91N0g2NfdDMu9V",
    "Nx8lYzc3eElEdy9mR",
]
scrambled = "".join(frags)
reversed_b64 = scrambled[::-1]          # same as [...s].reverse().join("")

print("concat len:  ", len(scrambled))
print("reversed b64:", reversed_b64)

plaintext = base64.b64decode(reversed_b64)
print("decoded:     ", plaintext)
print("len:", len(plaintext), "sha256:", hashlib.sha256(plaintext).hexdigest()[:12])

Output:

concat len:   64
reversed b64: Rm9ydElEe3czYl8xNV9uMDdfN2g0N19oNHJkXzQ1X3kwdV83aDFua18xN18xNX0=
decoded:      b'FortID{w3b_15_n07_7h47...'
len: 47 sha256: fcbe8e2d1302

The reversed string decodes cleanly to 47 printable ASCII bytes opening with FortID{ and closing with }, which is the flag format, so there is nothing left to confirm. The decode ran without a browser or a click anywhere in it.

Catching the button anyway

The offline decode is the shortest path, though it is worth knowing why the dodge fails on its own terms too. Each of these reveals the flag in the page itself:

Four ways to fire the handler without chasing anything
  • Dispatch the click from the console with document.querySelector("#runaway").click(), which runs the click listener directly. mouseenter never fires because no pointer ever moves.
  • Tab to the button with the keyboard and press Enter or Space. That path produces a real click event, and mouseenter is a pointer event, so runAway is never called. This one needs no devtools at all.
  • Remove the dodge with button.removeEventListener("mouseenter", runAway) in the page’s own scope, or pin the element from the elements panel with button.style.setProperty("left", "50%", "important"), then click it normally.
  • View source, or curl the page, and read the fragments straight out of the <script>.

They all produce the same text because the same constant is the only source of it.

Why the dodge was never a control

The mouseenter handler only ever protected one specific interaction: moving a physical pointer onto the element. It never protected the data, because the data and the code that decodes it were both delivered to the client.

This is the client-side-secret anti-pattern in its plainest form. The browser cannot render a secret it is unable to read, so any value the page can display is a value the user can extract, and obfuscation changes only how many seconds that takes. The same reasoning applies to a disabled submit button, a hidden form field, a paywall overlay drawn over an article, or a feature flag checked in JavaScript. Each describes what the interface is offering, while what the user can actually reach is decided by whatever the server checks.

Defence

Keep the secret on the server and release it only after a check the server performs itself, so the browser never holds it. For this page that means the click calls an endpoint, the endpoint decides whether the caller has earned the flag, and the response carries it only if so. Then a tampered button, a synthetic click or a direct request all hit the same server-side decision, and there is nothing in the delivered page worth reading.

The flagRevealFortID{w3b_15_n07_7h47_h4rd_45_y0u_7h1nk_17_15}

This CTF was possibly solved, and the writeup most likely written by an LLM. Apologies for inconsistencies.