DEV Community

Cover image for BroncoCTF : The KeyMaster Writeup
Yogeshwar Peela
Yogeshwar Peela

Posted on • Originally published at exploitnotes.hashnode.dev

BroncoCTF : The KeyMaster Writeup

Challenge

No file, no binary — just a URL:

https://broncosec.com/BroncoCTF
Enter fullscreen mode Exit fullscreen mode

The flag format is given as bronco{XXXX...}. Nothing else. This is an
OSINT / web-recon style challenge: the flag is broken into 8 numbered
fragments, scattered across the live site using a mix of static HTML,
downloadable files, and client-side JavaScript behavior. The goal is to
find all 8 pieces and assemble them in order.

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

Step 2 — grep for anything flag-shaped:

$ grep -rn "bronco{" .
$ grep -rnE "[0-9]+ - " .
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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 !!!" />
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

Concatenated:

bronco{h3y_y0u_f0und_th3m_4ll_w1th_4b501ut31y_n0_w0rr135_4t_411}
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

Key Takeaways

  • Static fetches only show you half the picture on modern JS-framework sites. Anything gated behind an onClick, a useEffect, or other client-side hydration logic is invisible to curl/basic web_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/*.js for 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 to grep for 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.

Top comments (0)