Challenge
A web page at https://broncoctf-unblur-me.chals.io/ presents a
"Calculus Review" quiz:
Solve 500 derivatives to unblur the image and unlock a secret.
Progress: 0 / 500
A blurred image sits behind a quiz form. The stated task is to answer
500 randomly-generated derivative problems in a row (get one wrong and
the counter resets to 0) in order to trigger the JavaScript that removes
the CSS blur() filter from the image. Doing that legitimately would be
slow and error-prone — worth checking whether the "500 correct answers"
gate is actually enforced anywhere but the browser.
Recon
Fetching the page source directly:
$ curl https://broncoctf-unblur-me.chals.io/
The interesting logic is all client-side JavaScript:
function checkAnswer() {
const userAnswer = parseInt(document.getElementById('answer').value);
if (userAnswer === currentAnswer) {
correctCount++;
...
if (correctCount >= 500) {
// reveal image
flag.style.filter = "none";
flag.style.pointerEvents = "auto";
}
}
}
function loadSecretImage() {
fetch('/api/v1/internal/fetch-config-blob')
.then(response => response.blob())
.then(blob => {
const blobUrl = URL.createObjectURL(blob);
document.getElementById('flag-image').src = blobUrl;
});
}
Two important observations:
The "solve 500 problems" gate is purely cosmetic.
correctCount
is a plain JavaScript variable living in the browser tab. The
if (correctCount >= 500)check that removes the blur filter runs
entirely client-side — there's no server-side verification that 500
problems were actually solved before the image becomes visible.The image itself is never gated at all.
loadSecretImage()runs
unconditionally on page load — before any quiz problems are even
answered — and fetches the full, unblurred image bytes straight from
/api/v1/internal/fetch-config-blob. The CSSfilter: blur(20px)
is applied only to the<img>element's rendering in the browser;
the actual image data sitting in the DOM (and in the network
response) was never blurred in the first place. Solving the quiz is
a complete red herring — the blur is a display-only effect layered
on top of a fully-formed image.
Exploit
No quiz-solving, no JavaScript execution, no exploitation required —
just fetch the endpoint directly and skip the browser entirely:
$ curl https://broncoctf-unblur-me.chals.io/api/v1/internal/fetch-config-blob -o out
$ file out
out: PNG image data, 1648 x 928, 8-bit/color RGBA, non-interlaced
Opening the resulting pic.png shows the flag directly, rendered as
text on a graphic — no CSS blur applied to the actual file, since the
blur only ever existed as a browser-side rendering filter.
Flag
Reading the flag text off the image:
bronco{1_wouldnt_m@k3_you_do_c@lculus}
Root Cause Summary
| Issue | Detail |
|---|---|
| Client-side-only access control | The "solve 500 derivatives" requirement is enforced entirely in browser JavaScript (a simple counter variable), with no corresponding check on the server. Anyone can bypass it by simply not running the page's JS at all. |
| Security theater via CSS | The image is never actually obscured at the data level — filter: blur() is a visual CSS effect applied only within the rendered page. The underlying bytes returned by the API are the full, clear image regardless of quiz progress. |
| Naming leak | The endpoint path /api/v1/internal/fetch-config-blob and the flag's own text both hint that this asset was never meant to require 500 correct answers — the puzzle is the misdirection, not a real barrier. |
Key Takeaways
-
Never trust logic that runs entirely in the client. Any gate,
counter, or "requirement" implemented purely in JavaScript running in
the user's own browser can be bypassed by simply not using that
browser code path — e.g., calling the underlying API directly with
curl. -
CSS effects are not security controls. A
blur(),display:none,visibility:hidden, or similar purely-visual CSS property does nothing to the underlying data — it only changes how a compliant browser renders it. The real data (and the request that fetched it) is fully accessible outside that rendering pipeline. - When a page implies a resource is "locked" behind some in-browser challenge, always check the Network tab (or just fetch known/guessable endpoints directly) — often the "lock" only exists in the UI layer.
Top comments (0)