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)
- Map the blacklist programmatically and diff against the tag universe -
styleis missing - Measure the bot with canaries: a CSS
@importof the report URL proves stylesheet GETs work; a navigation handler proves JS events; a fetch beacon fails - Deliver the payload as
style+onload- load events fire with zero interaction (animation events do not) - Use synchronous XHR inside the handler - fetch is excised from the bot, XHR is not; sync blocks until both requests complete
- Stage exfiltrated data as base64 comments on a different profile - the bot only wipes the profile it views
- Exfiltrate the admin console, then drive the admin user search with UNION SELECTs (6 columns) through
sqlite_masterto 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. No tag means no event handler - *almost* every classic auto-fire vector is dead.
- 2.
</script>would bypass the scan (it contains no<scriptsubstring) - 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:
| beacon | mechanism | what it proves if it fires |
|---|---|---|
canary comment on profile B; payload on A does location='/report/B' | JS event + navigation | event handlers fire at all |
CSS @import url('/report/B') | pure CSS, no JS | stylesheet requests happen |
fetch('/report/B') | JS + fetch API | fetch is available |
sync XMLHttpRequest to /report/B' | JS + XHR | XHR 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. onanimationstarthandler (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.
fetchof any flavor, GET or POST -> nothing.fetchis 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. 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.
- 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.
- 3. Exfil to nowhere. Winners notice there is *no* outbound internet and think
"in-band staging"; losers keep trying to phone home.
- 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.
- 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
Post a Comment