跨站指令碼 (XSS)

跨站指令碼攻擊 (XSS) 是一種攻擊者能夠讓目標站點執行惡意程式碼,使其看起來像是網站一部分的攻擊。

概述

網頁瀏覽器從許多不同的網站下載程式碼並在使用者的計算機上執行。其中一些網站高度可信,使用者可能會將其用於敏感操作,例如金融交易或醫療諮詢。而對於其他網站,例如休閒遊戲網站,使用者可能沒有這種信任關係。瀏覽器安全模型的基礎是這些站點應該相互隔離,因此來自一個站點的程式碼不應該能夠訪問另一個站點中的物件或憑據。這稱為同源策略

Diagram of 2 sites in the browsers, in separate worlds

在成功的 XSS 攻擊中,攻擊者能夠透過誘騙目標站點在其自己的上下文中執行惡意程式碼(使其看起來與目標站點同源)來顛覆同源策略。然後,該程式碼可以執行站點自身程式碼所能做的任何事情,例如:

  • 訪問和/或修改站點已載入頁面的所有內容,以及本地儲存中的任何內容
  • 使用使用者憑據發起 HTTP 請求,使攻擊者能夠冒充使用者或訪問敏感資料

Diagram of attacker code running in target website

所有 XSS 攻擊都取決於網站做兩件事

  1. 接受可能由攻擊者精心製作的某些輸入
  2. 將此輸入包含在頁面中,而未對其進行淨化:即,未確保其不會作為 JavaScript 可執行。

兩個 XSS 示例

在本節中,我們將透過兩個容易受到 XSS 攻擊的示例頁面。

瀏覽器中的程式碼注入

在此示例中,假設使用者銀行的網站是 my-bank.example.com。使用者通常已登入,網站中的程式碼可以訪問使用者的賬戶詳細資訊並執行交易。網站希望顯示一條針對當前使用者的個性化歡迎訊息。它將歡迎資訊顯示在標題元素中

html
<h1 id="welcome"></h1>

頁面期望在URL 引數中找到當前使用者的姓名。它提取引數值,並使用該值建立個性化問候訊息

js
const params = new URLSearchParams(window.location.search);
const user = params.get("user");
const welcome = document.querySelector("#welcome");

welcome.innerHTML = `Welcome back, ${user}!`;

假設此頁面從 https://my-bank.example.com/welcome 提供。為了利用此漏洞,攻擊者向用戶傳送如下連結

html
<a
  href="https://my-bank.example.com/welcome?user=<img src=x onerror=alert('hello!')>">
  Get a free kitten!</a
>

當用戶點選連結時

  1. 瀏覽器載入頁面。
  2. 頁面提取名為 user 的 URL 引數,其值為 <img src=x onerror=alert("hello!")>
  3. 然後,頁面將此值分配給 welcome 元素的 innerHTML 屬性,這會建立一個新的 <img> 元素,該元素的 src 屬性值為 x
  4. 由於 src 值生成錯誤,onerror 事件處理程式屬性被執行,攻擊者得以在頁面中執行其程式碼。

在這種情況下,程式碼只是顯示一個警報,但在真實的銀行網站中,攻擊者程式碼能夠執行銀行自己的前端程式碼所能做的任何事情。

伺服器中的程式碼注入

在此示例中,考慮一個具有搜尋功能的網站。搜尋頁面的 HTML 可能如下所示

html
<h1>Search</h1>

<form action="/results">
  <label for="mySearch">Search for an item:</label>
  <input id="mySearch" type="search" name="search" />
  <input type="submit" />
</form>

當用戶輸入搜尋詞並點選“提交”時,瀏覽器會向“/results”發出 GET 請求,並將搜尋詞作為 URL 引數包含在內,如下所示

https://example.org/results?search=bananas

伺服器希望顯示搜尋結果列表,標題指示使用者搜尋了什麼。它從 URL 引數中提取搜尋詞。這在 Express 中可能如下所示

js
app.get("/results", (req, res) => {
  const searchQuery = req.query.search;
  const results = getResults(searchQuery); // Implementation not shown
  res.send(`
   <h1>You searched for ${searchQuery}</h1>
   <p>Here are the results: ${results}</p>`);
});

為了利用此漏洞,攻擊者向用戶傳送如下連結

html
<a href="http://example.org/results?search=<img src=x onerror=alert('hello')">
  Get a free kitten!</a
>

當用戶點選連結時

  1. 瀏覽器向伺服器傳送 GET 請求。請求的 URL 引數包含惡意程式碼。
  2. 伺服器提取 URL 引數值並將其嵌入頁面。
  3. 伺服器將頁面返回給瀏覽器,瀏覽器執行它。

XSS 攻擊的剖析

與所有 XSS 攻擊一樣,這兩個示例之所以可能,是因為網站

  1. 使用可能由攻擊者精心製作的輸入
  2. 在頁面中包含輸入而未對其進行淨化。

這兩個示例都使用相同的載體來傳輸惡意輸入:URL 引數。但是,攻擊者可以使用其他載體。

例如,考慮一個帶評論的部落格。在這種情況下,網站

  1. 允許任何人使用 <form> 元素提交評論
  2. 將評論儲存在資料庫中
  3. 將評論包含在網站提供給其他使用者的頁面中。

如果評論未被淨化,那麼它們就是潛在的 XSS 載體。這種攻擊有時被稱為儲存型持久型 XSS,並且特別嚴重,因為受感染的內容將提供給所有訪問該頁面的使用者,每次他們訪問時都會如此。

客戶端和伺服器 XSS

這兩個示例之間的一個主要區別在於惡意程式碼注入在網站程式碼庫的不同部分,這反映了每個網站的架構。

使用客戶端渲染的網站,例如單頁應用,在瀏覽器中修改頁面,直接或間接透過 React 等框架使用 document.createElement() 等 Web API 進行修改。XSS 注入將在此過程中發生。這正是我們在第一個示例中看到的:惡意程式碼由頁面中執行的指令碼透過將 URL 引數值分配給 Element.innerHTML 屬性來注入瀏覽器,該屬性將其值解釋為 HTML 程式碼。

使用伺服器端渲染的網站在伺服器上構建頁面,使用 Django 或 Express 等框架,最常見的是將值插入頁面模板中。XSS 注入(如果發生)將在模板化過程中在伺服器中發生。這正是我們在第二個示例中看到的:程式碼由 Express 程式碼將 URL 引數值插入其返回的文件中來注入伺服器。然後,當瀏覽器評估頁面時,XSS 攻擊程式碼會執行。

在這兩種情況下,防禦的通用方法是相同的,我們將在下一節中詳細介紹。但是,您將使用的特定工具和 API 將有所不同。

XSS 防禦

如果您需要在站點頁面中包含外部輸入,XSS 的主要防禦措施有兩項

  1. 使用輸出編碼淨化來防止輸入變為可執行。如果您在瀏覽器中渲染內容,可以使用 Trusted Types API 來確保輸入在包含到頁面之前經過了淨化函式處理。
  2. 使用內容安全策略 (CSP) 告訴瀏覽器允許執行哪些 JavaScript 或 CSS 資源。這是一種備用防禦:如果第一道防線失敗,可執行輸入進入頁面,那麼正確配置的 CSP 應該可以防止瀏覽器執行它。

輸出編碼

輸出編碼是將輸入字串中可能使其危險的字元進行轉義的過程,因此它們被視為文字而不是 HTML 等語言的一部分。

當您希望將輸入視為文字時,這是一個合適的選擇,例如,因為您的網站使用將輸入插入內容的模板,如以下 Django 模板摘錄

django
<p>You searched for {{ search_term }}.</p>

大多數現代模板引擎都會自動執行輸出編碼。例如,Django 的模板引擎執行以下轉換

  • < 轉換為 &lt;

  • > 轉換為 &gt;

  • ' 轉換為 &#x27;

  • " 轉換為 &quot;

  • & 轉換為 &amp;

這意味著如果您將 <img src=x onerror=alert('XSS!')> 傳遞到上面的 Django 模板中,它將轉換為 &lt;img src=x onerror=alert(&#x27;XSS!&#x27;)&gt;,並顯示為以下文字

您搜尋了 <img src=x onerror=alert('XSS!')>。

類似地,如果您使用 React 進行客戶端渲染,嵌入在 JSX 中的值會自動編碼。例如,考慮以下 JSX 元件

jsx
import React from "react";

export function App(props) {
  return <div>Hello, {props.name}!</div>;
}

如果我們將 <img src=x onerror=alert('XSS!')> 傳遞給 props.name,它將渲染為

你好,<img src=x onerror=alert('XSS!')>!

防止 XSS 攻擊最重要的部分之一是使用效能良好且執行可靠輸出編碼的模板引擎,並閱讀其文件以瞭解其提供的保護的任何注意事項。

文件上下文

即使您使用自動編碼 HTML 的模板引擎,您也需要了解在文件的哪個位置包含不受信任的內容。例如,假設您有一個 Django 模板如下

django
<div>{{ my_input }}</div>

在此上下文中,輸入位於 <div> 標籤內,因此瀏覽器將其評估為 HTML。因此,您需要防範 my_input 是定義可執行程式碼的 HTML 的情況,例如 <img src=x onerror="alert('XSS')">。Django 內建的輸出編碼透過將 <> 等字元編碼為 HTML 實體 &lt;&gt; 來防止這種攻擊。

但是,假設模板如下

django
<div {{ my_input }}></div>

在此上下文中,瀏覽器會將 my_input 變數視為 HTML 屬性。由於 Django 對引號進行編碼("&quot;'&#x27;),因此有效載荷 onmouseover="alert('XSS')" 將不會執行。然而,像 onmouseover=alert(1)(或使用反引號 onmouseover=alert(`XSS`))這樣的未加引號的有效載荷仍然會執行,因為屬性值不需要加引號,並且反引號預設不被轉義。

瀏覽器使用不同的規則來處理網頁的不同部分——HTML 元素及其內容、HTML 屬性、內聯樣式、內聯指令碼。需要進行的編碼型別取決於插入輸入的上下文。

在一個上下文中安全的內容在另一個上下文中可能不安全,因此有必要理解您包含不受信任內容的上下文,並實現此上下文所需的任何特殊處理。

  • HTML 上下文:插入到大多數 HTML 元素標籤之間(除了 <style><script>)的輸入被解釋為 HTML。模板引擎應用的編碼主要關注此上下文。

  • HTML 屬性上下文:將輸入作為 HTML 屬性值插入有時是安全的,有時則不安全,具體取決於屬性。特別是,像 onblur 這樣的事件處理程式屬性是不安全的,<iframe> 元素的 src 屬性也是如此。

    為插入的屬性值新增引號也很重要,否則攻擊者可能會在提供的值中插入一個額外的非安全屬性。例如,此模板未對插入的值新增引號

    django
    <div class={{ my_class }}>...</div>
    

    攻擊者可以透過使用 some_id onmouseover=alert(1) 這樣的輸入來利用此漏洞注入事件處理程式屬性。為了防止攻擊,請給佔位符加上引號

    django
      <div class="{{ my_class }}">...</div>
    
  • JavaScript 和 CSS 上下文:在 <script><style> 標籤內插入輸入幾乎總是不安全的。

淨化

模板引擎通常允許開發人員停用輸出編碼。當開發人員希望將不受信任的內容作為 HTML 而不是文字插入時,這是必要的。例如,在 Django 中,safe 過濾器會停用輸出編碼,而在 React 中,dangerouslySetInnerHTML 具有相同的效果。

在這種情況下,開發人員有責任透過淨化內容來確保內容安全。

淨化是刪除 HTML 字串中不安全功能的過程:例如,<script> 標籤或內聯事件處理程式。由於淨化和輸出編碼一樣難以正確實現,因此建議使用信譽良好的第三方庫。包括 OWASP 在內的許多專家都推薦 DOMPurify

例如,考慮一個 HTML 字串,例如

html
<div>
  <img src="x" onerror="alert('hello!')" />
  <script>
    alert("hello!");
  </script>
</div>

如果我們將此傳遞給 DOMPurify,它將返回

html
<div>
  <img src="x" />
</div>

可信型別

擁有一個可以淨化給定輸入字串的函式是一回事,但在程式碼庫中找到所有需要淨化輸入字串的位置本身可能是一個非常困難的問題。

如果您在瀏覽器中實現客戶端渲染,那麼如果使用未淨化的不受信任內容呼叫一些 Web API,它們將是不安全的。

例如,以下 API 將其字串引數解釋為 HTML 並使用它來更新頁面 DOM

其他 API 直接將其引數作為 JavaScript 執行。例如

Trusted Types API 使開發人員能夠確保輸入在傳遞給這些 API 之前始終經過淨化。

強制使用可信型別的關鍵是 require-trusted-types-for CSP 指令。如果設定了此指令,則將字串引數傳遞給不安全的 API 將丟擲異常

js
const userInput = "I might be XSS";
const element = document.querySelector("#container");

element.innerHTML = userInput; // Throws a TypeError

相反,開發人員必須將可信型別傳遞給這些 API 之一。可信型別是由 TrustedTypePolicy 物件從字串建立的物件,其實現由開發人員定義。例如

js
// Create a policy that can create TrustedHTML values
// by sanitizing the input strings with DOMPurify library.
const sanitizer = trustedTypes.createPolicy("my-policy", {
  createHTML: (input) => DOMPurify.sanitize(input),
});

const userInput = "I might be XSS";
const element = document.querySelector("#container");

const trustedHTML = sanitizer.createHTML(userInput);
element.innerHTML = trustedHTML;

注意: Trusted Types API 不提供淨化功能:它是一個框架,開發人員可以在其中確保他們提供的淨化功能已被呼叫。在上面的示例中,開發人員在 Trusted Types 框架中使用 DOMPurify 作為 HTML 接收器的淨化器。

Trusted Types API 尚未獲得良好的跨瀏覽器支援,但一旦獲得,它將成為針對基於 DOM 的 XSS 攻擊的重要防禦措施。

部署 CSP

輸出編碼和淨化都是為了防止惡意指令碼進入站點頁面。內容安全策略的主要功能之一是即使惡意指令碼在站點頁面中也能阻止其執行。也就是說,它是其他防禦失敗時的備用方案。

使用 CSP 緩解 XSS 的推薦方法是嚴格 CSP,它使用隨機數雜湊來指示瀏覽器它期望在文件中看到哪些指令碼。如果攻擊者設法插入惡意 <script> 元素,那麼它們將沒有正確的隨機數或雜湊,並且瀏覽器將不會執行它們。此外,各種常見的 XSS 向量被完全停用:內聯事件處理程式、javascript: URL 和像 eval() 這樣將其引數作為 JavaScript 執行的 API。

防禦總結清單

  • 在瀏覽器或伺服器中將輸入插入頁面時,請使用執行輸出編碼的模板引擎。
  • 注意您插入輸入的上下文,並確保在該上下文中執行適當的輸出編碼。
  • 如果您需要將輸入作為 HTML 包含,請使用信譽良好的庫對其進行淨化。如果您在瀏覽器中執行此操作,請使用可信型別框架來確保輸入由您的淨化函式處理。
  • 實施嚴格的 CSP。

另見