BroncoCTF : The KeyMaster Writeup
Recon
Fetching the page's rendered HTML directly gives a first look. Most of the page is a fairly standard "CTF landing page" - hero banner, stats counters, sponsor logo, footer credits. Scanning through it for anything that looks out of place (odd alt text, title attributes, query strings, filenames) turns up four fragments immediately, sitting in plain sight in the static markup:
| # | Fragment | Location |
|---|---|---|
| 1 | bronco{h |
Plain text at the very end of the page footer, after the credits |
| 3 | 0und_th3 |
title attribute on the "Join the Competition" button/link |
| 6 | ut31y_n0 |
Query string on the "BroncoCTF 2026...?" repository card: href="/BroncoCTF?KEY=6-ut31y_n0" |
| 8 | _4t_411} |
alt text on the sixth stats card (the one using correct-flag-colorized.svg) |
That's 4 of 8, found just by reading the rendered HTML carefully.
Piece 7 - a hidden download
The "About the Competition" text includes: BroncoCTF 2026 will be our fifth CTF... The word "2026" is a hyperlink to /7.txt, disguised as normal body text (no obvious styling gives it away as a link in the rendered page). Fetching it directly:
$ curl https://broncosec.com/7.txt
7 - _w0rr135
Piece 7 found.
Pieces 2, 4, 5 - hidden in client-side JavaScript
The remaining three fragments never appear in the static HTML at all - they're generated at runtime by JavaScript event handlers, meaning a plain curl/fetch of the page will never reveal them. The fix is to pull down the site's actual JS bundles and grep them directly.
Step 1 - enumerate the script chunks
Looking at the <script src=...> tags in the raw page source (Next.js app, so chunks are hashed filenames under /_next/static/chunks/):
$ curl -sO https://broncosec.com/_next/static/chunks/e785679bf8074938.js
$ curl -sO https://broncosec.com/_next/static/chunks/f31cf569852813cb.js
... (and the rest of the referenced chunks)
Step 2 - grep for anything flag-shaped
$ grep -rn "bronco{" .
$ grep -rnE "[0-9]+ - " .
This immediately surfaces three onClick handlers and one DOM-injection trick buried in the minified React component source:
Fragment 2 - hidden behind a click handler on the word "flags":
onClick: () => {
let e = document.getElementById("addtext");
e && e.firstChild && (e.firstChild.textContent += "2 - 3y_y0u_f");
new Audio("ding.oga").play();
}
Clicking the word "flags" in the body text appends 2 - 3y_y0u_f into a hidden <div id="addtext"> on the page and plays a sound. A static HTML fetch will never show this - it only exists after the click event fires and mutates the DOM.
Fragment 4 - hidden behind clicking the ๐ emoji:
onClick: () => {
document.cookie = "KEY4=4 - m_4ll_w1; path=/";
...
new Audio("puzzle.oga").play();
}
Clicking the raised-hand emoji under "Online Bragging Rights" sets a cookie named KEY4 containing the fragment, and appends a ๐ช emoji next to it as visual confirmation.
Fragment 5 - hidden as a dynamically-injected HTML comment:
function a({comment: e}) {
let n = useRef(null);
useEffect(() => {
n.current && (n.current.outerHTML = `<!-- ${e} -->`);
}, [e]);
return <script ref={n} type="text/placeholder" />;
}
...
<a comment="!!! 5 - th_4b501 !!!" />
A placeholder <script> tag gets replaced, after mount, with an HTML comment containing the fragment. This only exists in the live DOM after React hydrates - invisible both to a static curl and to "view source", since browsers' "view source" shows the original server-rendered HTML, not the post-hydration DOM. Only "Inspect Element" (which reflects the live DOM) or a JS-aware fetch would catch it.
Assembling the flag
Ordering all 8 recovered fragments by their embedded index:
1: bronco{h
2: 3y_y0u_f
3: 0und_th3
4: m_4ll_w1
5: th_4b501
6: ut31y_n0
7: _w0rr135
8: _4t_411}
Concatenated:
bronco{h3y_y0u_f0und_th3m_4ll_w1th_4b501ut31y_n0_w0rr135_4t_411}
Leetspeak-decoded, this reads: "hey you found them all with absolutely no worries at all" - a fitting message for a challenge literally about finding every scattered fragment.
Flag
bronco{h3y_y0u_f0und_th3m_4ll_w1th_4b501ut31y_n0_w0rr135_4t_411}
Key Takeaways
- Static fetches only show you half the picture on modern JS-framework sites. Anything gated behind an
onClick, auseEffect, or other client-side hydration logic is invisible tocurl/basicweb_fetch- you have to either pull and read the actual JS bundles, or interact with a live, JS-executing browser (DevTools/Inspect Element) to see DOM state that only exists after the page runs. - Downloading and grepping the full JS bundle set (
/_next/static/chunks/*.jsfor Next.js apps) is a fast, reliable way to recover client-side logic and embedded strings without needing to manually trigger every interaction in a browser first. - Minified React source is still greppable. Even heavily minified JSX compiles down to recognizable patterns (
onClick:, string literals, JSX attribute names) that survive minification well enough togrepfor target patterns like flag formats or numbered fragments. - Distinguish "view source" vs. "Inspect Element": view-source shows the original server response; Inspect Element (or any DOM-reading tool) shows the current, JS-mutated state. Challenges that hide data in post-hydration DOM changes require the latter.
Comments
No comments yet. Start the discussion.