【JS】1891- 悄无声息间,你的 DOM 被劫持了?

文档对象模型(DOM)充当着 HTMLJavaScript 之间的接口,搭建起静态内容与动态交互之间的桥梁,对现代 Web 开发而言,DOM 的作用不可或缺。

然而,DOM 也有一个致命的陷阱 —— DOM 劫持。DOM 劫持是指当 HTML 元素与全局 JavaScript 变量或函数产生冲突时,可能会导致 Web 应用程序出现不可预期的行为,甚至产生潜在的安全漏洞。

今天就可大家一起来聊聊 DOM 劫持的问题。

DOM 劫持是怎么发生的?

每个 HTML 元素都可以有一个唯一的 idname 属性,方便在 JavaScript 中引用特定的元素。例如,下面的 HTML 按钮具有一个值为 "myButton"id 属性:

代码语言:javascript
复制
<button id="myButton">Click Me!</button>

我们可以在 JavaScript 代码中使用此 ID 来操作按钮,例如,当点击时改变其文本:

代码语言:javascript
复制
document.getElementById('myButton').onclick = function() {
    this.textContent = 'Clicked!';
};

如果 HTML 元素的 idname 属性与全局 JavaScript 变量或函数冲突会发生什么呢?

当浏览器加载 HTML 页面时,它会自动为 HTML DOM 中的每个 idname 属性创建全局 JavaScript 变量。如果我们有一个名为 “myButton”HTML 元素,浏览器会创建一个全局 JavaScript 变量 myButton,引用该 HTML 元素。

现在,让我们考虑一个场景,其中我们声明了一个名为 myButtonJavaScript 函数:

代码语言:javascript
复制
function myButton() {
    // some code
}

但我们还有一个 idname“myButton”HTML 元素。当页面加载时,浏览器的自动进程会引用 HTML 元素并覆盖 JavaScript 函数 myButton

代码语言:javascript
复制
<button id="myButton">Click Me!</button>

console.log(myButton); // This will log the HTML button element, not the function

这个过程叫做 DOM 劫持,它可能会引发不可预测的行为和安全漏洞。如果攻击者能控制这些属性,他们可能有能力向网页注入恶意代码,从而引发包括跨站脚本(XSS)在内的安全问题。

为了说明这一点,让我们考虑以下情景:

代码语言:javascript
复制
Enter your name: <input id="username" type="text">
<button onclick="greet()">Greet</button>

function greet() {
var username = document.getElementById('username').value;
alert(Hello ${username});
}

攻击者可以输入类似 <img id='alert' src=x onerror='alert(1)'> 的内容,创建一个 'id''alert' 的新 HTML 组件。该组件会破坏 JavaScript 中的正常 alert 功能。下次网站尝试使用此功能时,它将无法正常工作,甚至可能运行恶意代码。

我们想象现在有一个带有用户反馈功能的基本 Web 应用程序。用户输入自己的姓名和反馈消息,然后提交。页面显示反馈:

html:

代码语言:javascript
复制
<h2>Feedback Form</h2>
<form>
<label for="name">Name:</label><br>
<input type="text" id="name" name="name"><br>
<label for="feedback">Feedback:</label><br>
<textarea id="feedback" name="feedback"></textarea><br>
<input type="submit" value="Submit">
</form>

<div id="feedbackDisplay"></div>

JavaScript:

代码语言:javascript
复制
document.querySelector('form').onsubmit = function(event) {
event.preventDefault();

let name = document.getElementById(&#39;name&#39;).value;
let feedback = document.getElementById(&#39;feedback&#39;).value;

let feedbackElement = document.getElementById(&#39;feedbackDisplay&#39;);

feedbackElement.innerHTML = `&lt;p&gt;&lt;b&gt;${name}&lt;/b&gt;: ${feedback}&lt;/p&gt;`;

};

这段代码会获取用户的姓名和反馈,并将其显示在 FeedbackDisplay div 内的段落元素中。

攻击者可以通过在反馈表单中提交一段 HTML 来利用此代码。例如,如果他们在名称字段中输入以下代码并提交表单,则反馈显示区域就会被 Script 替换:

代码语言:javascript
复制
<script id="feedbackDisplay">window.location.href='http://conardli.top';</script>

当表单尝试显示下一条反馈时,就会执行脚本,将用户重定向到恶意网站。这是 DOM 劫持造成严重后果的一个例子 —— 攻击者可以控制用户的浏览器,从而窃取敏感数据或安装恶意软件。

缓解 DOM 劫持的安全编码实践

通过更深入地了解这些漏洞,我们可以继续采取一些最佳实践来减轻 DOM 劫持的风险。

正确定义变量和函数的作用域

DOM 劫持的最常见原因之一是滥用 JavaScript 中的全局作用域。

通过在特定的作用域范围内定义变量和函数,我们可以限制对该范围或任何嵌套范围的覆盖,并最大限度地减少潜在的冲突。

我们来用 JavaScript 的作用域规则并重构前面的示例来展示如何做到这一点:

代码语言:javascript
复制
(function() {
// All variables and functions are now in this function's scope
const form = document.querySelector('form');
const feedbackElement = document.getElementById('feedbackDisplay');

form.onsubmit = function(event) {
    event.preventDefault();

    const name = document.getElementById(&#39;name&#39;).value;
    const feedback = document.getElementById(&#39;feedback&#39;).value;

    // Sanitize user input
    name = DOMPurify.sanitize(name);
    feedback = DOMPurify.sanitize(feedback);

    const newFeedback = document.createElement(&#39;p&#39;);
    newFeedback.textContent = `${name}: ${feedback}`;
    feedbackElement.appendChild(newFeedback);
};

})();

首先我们使用了 DOMPurify 来对上述代码块中的用户输入进行清理。

在此版本的代码中,我们将所有内容都包含在立即调用函数表达式 (IIFE) 中,这会创建一个新作用域。formFeedbackElement 变量以及分配给 onsubmit 事件处理程序的函数不在全局作用域内,因此它们不能被劫持。

使用唯一标识符

确保网页上的每个元素都有唯一的 id 可以降低无意中覆盖重要函数或变量的风险。另外,避免使用通用名称或可能与全局 JavaScript 对象或函数冲突的名称。

避免全局命名空间污染

保持全局命名空间干净是编写安全 JavaScript 的一个重要方面。全局作用域中的变量和函数越多,DOM劫持的风险就越大。使用 JavaScript 的函数作用域或 ES6 的块作用域来保留变量和函数。这是使用后者的示例:

代码语言:javascript
复制
    let form = document.querySelector('form');
let feedbackElement = document.getElementById('feedbackDisplay');

form.onsubmit = function(event) {
    event.preventDefault();

    let name = document.getElementById(&#39;name&#39;).value;
    let feedback = document.getElementById(&#39;feedback&#39;).value;

    // Sanitize user input
    name = DOMPurify.sanitize(name);
    feedback = DOMPurify.sanitize(feedback);

    let newFeedback = document.createElement(&#39;p&#39;);
    newFeedback.textContent = `${name}: ${feedback}`;
    feedbackElement.appendChild(newFeedback);
};

在这段代码中,我们使用块(由 {} 定义)来创建新作用域。所有变量和函数现在都限制在该块中,并且不在全局作用域内。

正确使用 JavaScript 特性

现代 JavaScript 提供了一些有助于最大限度地缓解 DOM 劫持的风险。特别是 ES6 中引入的 letconst 关键字提供了对声明变量的更多控制。

在以前,我们使用 var 关键字声明 JavaScript 变量。var 有一些怪癖,其中之一是就它没有块作用域,只有函数作用域和全局作用域。这意味着用 var 声明的变量可以在声明它的块之外访问和覆盖。

另一方面,letconst 都具有块作用域,这意味着它们只能在声明它们的块内访问。这一特性通常使它们成为变量声明的更好选择,因为它限制了覆盖变量的可能性。

我们还可以使用 const 来声明常量 — 分配它们后我们无法更改的值。它们可以防止重要的变量被意外覆盖。

代码语言:javascript
复制
    const form = document.querySelector('form');
const feedbackElement = document.getElementById('feedbackDisplay');

form.onsubmit = function(event) {
    event.preventDefault();

    const name = document.getElementById(&#39;name&#39;).value;
    const feedback = document.getElementById(&#39;feedback&#39;).value;

    // Sanitize user input
    name = DOMPurify.sanitize(name);
    feedback = DOMPurify.sanitize(feedback);

    const newFeedback = document.createElement(&#39;p&#39;);
    newFeedback.textContent = `${name}: ${feedback}`;
    feedbackElement.appendChild(newFeedback);
};

在此代码中,我们将所有 var 的使用替换为 const。我们将所有变量限制在声明它们的块中,并且常量不能被覆盖。

但是 ,使用 letconst 并不能完全消除 DOM 劫持的风险,但这种做法仍然是安全编码的一个关键方面。

使用 Devtools 发现潜在的 DOM 劫持风险

例如 ChromeFirefox 中的浏览器开发者工具,也是探测 DOM 劫持漏洞的强大助手。

最简单的方法,我们直接打开 Devtools

然后在控制台输入 window ,这里面包含了网站全局作用域下所有的全局变量和函数。

然后我们检查下是否有任何看起来不合适的变量,尤其是那些与 HTML 元素 idname 同名的变量。

通过 Elements 选项卡,编辑页面的 HTML 来操控 DOM 并测试潜在的漏洞。例如,添加一个 id 与全局变量或函数相匹配的元素,看看是否会被覆写。