247CTF // Helicopter Administrators - XSS When fetch Is Dead

247CTF // Helicopter Administrators - XSS When fetch Is Dead

Category: stored XSS, transport triage, second-order SQLi

Stack: PHP, SQLite, headless Chrome (JS on, fetch removed, animations ignored)


STEPS
  1. Map the blacklist programmatically and diff against the tag universe - style is missing
  2. Measure the bot with canaries: a CSS @import of the report URL proves stylesheet GETs work; a navigation handler proves JS events; a fetch beacon fails
  3. Deliver the payload as style + onload - load events fire with zero interaction (animation events do not)
  4. Use synchronous XHR inside the handler - fetch is excised from the bot, XHR is not; sync blocks until both requests complete
  5. Stage exfiltrated data as base64 comments on a different profile - the bot only wipes the profile it views
  6. Exfiltrate the admin console, then drive the admin user search with UNION SELECTs (6 columns) through sqlite_master to the flag table

1. The target

A tiny social app: profiles at /user/1..3, an admin console at /user/0 gated on

both an admin session flag and a network-position check, a stored-comment

system behind an exhaustive tag blacklist, and an admin bot that views reported

profiles within seconds, performs no interaction, and wipes the viewed profile's

comments afterwards. The loot is a SQLite database reachable through an admin-only

user search.

The bot hints, verbatim: *lazy (won't interact), memory hungry (uses Chrome), simple

(won't be affected by anything fancy), impatient, aggressive (deletes your comments).*

Every hint is load-bearing. This writeup treats them as a spec and reverse-engineers

the runtime from behavior, because the environment is weirder than the source.

2. Mapping the filter

Posting <img src=x> returns the full blacklist in the error message. Parse it

programmatically and diff it against the HTML tag universe:

  • Everything that auto-fires an event handler is on it: svg, img, details,

marquee, iframe, input, video, audio, body, keygen...

  • <style> is not on it. Neither are the MathML tags, template, plaintext.

The check is a case-insensitive substring scan for <tag on the raw comment. Two

consequences:

  1. 1. No tag means no event handler - *almost* every classic auto-fire vector is dead.
  2. 2. </script> would bypass the scan (it contains no <script substring) - useful

only if a script context existed, and none does. Noted, parked.

<style> is the gap. The question becomes what a stylesheet can do in a browser

with no interaction.

3. Measuring the bot - canaries per transport

Do not write the real payload first. Write beacons that distinguish hypotheses,

because the bot's runtime is the actual challenge:

beaconmechanismwhat it proves if it fires
canary comment on profile B; payload on A does location='/report/B'JS event + navigationevent handlers fire at all
CSS @import url('/report/B')pure CSS, no JSstylesheet requests happen
fetch('/report/B')JS + fetch APIfetch is available
sync XMLHttpRequest to /report/B'JS + XHRXHR is available

Reading the results: profile B's canary disappearing means B was *visited*, which

means the bot's browser requested /report/B - the beacon fired. (Your own GETs of

profile B never wipe it; only bot visits do. Verify this assumption once with a

control canary you never beacon.)

The observed matrix:

  • CSS @import -> canary wiped. Stylesheet requests happen. Pure CSS works.
  • onanimationstart handler (any payload) -> nothing. Animations never fire -

the "won't be affected by fancy animations" hint, literal.

  • <style onload=fetch(...)> -> nothing. Either the event doesn't fire, or fetch is dead.
  • <style onload=location='/report/B'> -> canary wiped. The load event fires,

JS executes, navigation works.

  • fetch of any flavor, GET or POST -> nothing. fetch is excised from the bot.
  • XMLHttpRequest, sync and async -> fires.

So the payload primitive is: <style onload="...sync XHR...">. The animation-based

vectors were double-dead (animations off), which is why the hint exists - to stop

you burning hours on onanimationstart before testing onload.

Why does <style onload> work when img onerror is blacklisted? Because the filter

authors thought in tags, not in events. A stylesheet's load event is guaranteed by

the HTML spec the moment the element's text is processed; nobody thinks of <style>

as an interactive element.

4. Why sync XHR

The bot is impatient - it views for seconds. Async chains (fetch /user/0, *then*

POST the result) risk the page being torn down between the two requests. A

synchronous XHR blocks the parser; the bot cannot proceed, let alone leave, until

both requests complete. Serialization is your friend when the counterparty is in a

hurry.

5. Staging: surviving the aggressive janitor

The bot deletes comments on the profile it *views*. Your payload lives on the

reported profile - it will be executed and then deleted. Anything it exfiltrates

must be posted as a comment on a different profile before the visit ends.

Two refinements:

  • Base64-wrap the loot (B64: + btoa(body)). Raw admin-console HTML contains

sequences that would trip the *comment* blacklist when re-posted.

  • Post to a profile nobody will ever report or visit; those comments persist.

6. The chain, stage by stage

Stage 0 - recon. Exfiltrate the admin console as base64. It contains the

"User ID Search" form pointing at a POST endpoint with a single parameter.

Stage 1 - enumerate the database, blind but fat. One payload loops several search

terms through sync XHR and concatenates every response:

  • plain ids 0,1,2,3,4,-1 - the normal rows, establishing the response shape;
  • ' OR '1'='1 - a classic tautology: if it returns extra rows, SQLi confirmed;
  • a deliberately wrong-arity UNION - the column-count error message is the

fastest way to learn the schema width.

The wrong-arity probe is the elegant one: SQLite's error text tells you exactly how

many columns the query returns. From the normal rows you already suspect six; the

error confirms it.

Stage 2 - schema. 0 UNION SELECT name,2,3,4,5,6 FROM sqlite_master lists

tables; the same against sql returns the DDL - a one-column flag table.

Stage 3 - the loot. 0 UNION SELECT flag,2,3,4,5,6 FROM flag. Base64-wrapped,

staged, decoded.

Each stage is one bot visit. The total cost is a handful of visits and zero

interaction from the bot.

7. The payload, generalized


<style onload="

  var x = new XMLHttpRequest();

  x.open('POST', '<admin-endpoint>', false);

  x.setRequestHeader('Content-Type','application/x-www-form-urlencoded');

  x.send('<terms>');

  var y = new XMLHttpRequest();

  y.open('POST', '/comment/<stage>', false);

  y.setRequestHeader('Content-Type','application/x-www-form-urlencoded');

  y.send('comment=' + encodeURIComponent('B64:' + btoa(x.responseText)));

"></style>

Blacklist audit before sending: no <tag substring from the list appears anywhere

in the attribute text (onload, XMLHttpRequest, the endpoint paths are all clean).

8. Why this challenge has so few finishers

Every stage punishes a different failure of imagination:

  1. 1. Filter-as-sandbox thinking. People burn hours fuzzing tags. The bypass is a

tag the authors forgot exists, carrying an event nobody attributes to it.

  1. 2. Assuming the runtime. CTF players default to fetch/onerror. Here fetch is

gone and the event has to be measured into existence. The challenge is 30%

XSS, 70% environment characterization.

  1. 3. Exfil to nowhere. Winners notice there is *no* outbound internet and think

"in-band staging"; losers keep trying to phone home.

  1. 4. The aggressive janitor. Exfil staged on the viewed profile dies with the

visit. The staging-profile trick is a one-line insight that doubles solve rate.

  1. 5. Second-order SQLi. Even with the admin endpoint, you must reach it *through*

the bot - the SQLi alone is unreachable, the XSS alone is lootless.

Takeaways:

  • Whitelist filters enumerate what authors remembered. Diff the list against the

full tag universe; the complement is your attack surface.

  • Treat bot runtimes as unmeasured APIs: build the smallest beacon that discriminates

each hypothesis (CSS-only, JS-event, transport) before composing the payload.

  • Synchronous XHR is the poor man's guarantee against an impatient counterparty.
  • "They delete my payload after execution" is a routing problem: make the payload's

*output* land somewhere the janitor doesn't sweep.

Comments

Popular Posts