JavaScript 原型汙染

原型鏈汙染是一種漏洞,攻擊者可以透過它在物件的原型上新增或修改屬性。這意味著惡意值可能會意外地出現在應用程式中的物件上,通常會導致邏輯錯誤或額外的攻擊,例如跨站指令碼 (XSS)

JavaScript 中的原型

JavaScript 使用原型實現繼承。每個物件都引用一個原型,原型本身也是一個物件,並且原型本身也有一個原型,依此類推,直到我們得到最基本原型,它被稱為Object.prototype,其自身原型是null

如果你嘗試訪問物件上的屬性或呼叫方法,並且該屬性或方法未在物件上定義,那麼 JavaScript 執行時會首先在物件的原型中查詢該屬性或方法,然後在其原型的原型中查詢,依此類推,直到找到該方法或屬性,或者到達一個原型為null的物件。

這就是為什麼你可以這樣做

js
const mySet = new Set([1, 2, 3]);
// prototype chain:
// mySet -> Set.prototype -> Object.prototype -> null

mySet.size;
// 3
// size is defined on the prototype of `mySet`, which is `Set.prototype`

mySet.propertyIsEnumerable("size");
// false
// propertyIsEnumerable() is defined on the prototype
// of `Set.prototype`, which is `Object.prototype`

與許多其他語言不同,JavaScript 允許你透過修改物件的原型在執行時新增繼承的屬性和方法

js
const mySet = new Set([1, 2, 3]);

// modify the Object prototype at runtime
Object.prototype.extra = "new property from the Object prototype!";

// modify the Set prototype at runtime
Set.prototype.other = "new property from the Set prototype!";

mySet.extra;
// "new property from the Object prototype!"

mySet.other;
// "new property from the Set prototype!"

在原型鏈汙染攻擊中,攻擊者會更改內建原型(例如Object.prototype),導致所有派生物件都具有額外的屬性,包括攻擊者無法直接訪問的物件。

注意:要了解更多關於原型的資訊,請參閱

原型鏈汙染的剖析

原型鏈汙染涉及兩個階段

  1. 汙染:攻擊者能夠在物件的原型上新增或修改屬性。
  2. 利用:原始應用程式程式碼訪問受汙染的屬性,導致意外行為。

汙染源

為了汙染物件,攻擊者需要一種方法來向原型物件新增任意屬性。這可能是XSS的結果,攻擊者獲得了頁面 JavaScript 執行環境的直接訪問許可權。然而,具有這種訪問級別的攻擊者可以更直接地造成損害,因此原型汙染通常被討論為一種僅資料攻擊,即攻擊者構建由應用程式程式碼處理的有效負載,從而導致汙染。

一個關鍵的攻擊向量是__proto__屬性,它允許訪問任意物件的原型物件。你還可以透過yourObject.constructor.prototype訪問原型。作為汙染源的關鍵程式碼模式是以下型別的動態屬性修改:

js
obj[key1][key2] = value;

在這種情況下,如果obj是一個普通物件,key1"__proto__",並且key2是某個屬性名稱(例如"test"),那麼程式碼會將一個名為test的屬性新增到Object.prototype,這是所有普通物件的原型。即使"__proto__" setter 被停用.constructor.prototype訪問模式仍然可以用來訪問原型,對於普通物件來說,它也是Object.prototype

js
obj[key1][key2][key3] = value;

...其中key1"constructor"key2"prototype"key3是某個屬性名稱(例如"test")。

將此行放入更多上下文中,key1key2key3可能是攻擊者控制的值。例如,想象一個 API 端點,它接受使用者名稱列表和每個使用者要查詢的欄位列表,並返回一個將每個使用者名稱對映到其欄位的物件

js
function getUsers(request) {
  const result = {};
  const userNames = new URL(request.url).searchParams.getAll("names");
  const fields = new URL(request.url).searchParams.getAll("fields");
  for (const name of userNames) {
    const userInfo = database.lookup(name);
    result[name] ??= {};
    for (const field of fields) {
      // Pollution source
      result[name][field] = userInfo[field];
    }
  }
  return result;
}

現在,如果攻擊者使用 URL https://example.com/api?names=__proto__&fields=age 呼叫此 API,則程式碼會將名為 age 的屬性新增到 Object.prototype,其值將是 __proto__ 使用者的 age 屬性的值。它可能是 undefined,但如果攻擊者可以將名為 __proto__ 的使用者新增到資料庫(例如,透過單獨的 API 呼叫),他們就可以控制 age 屬性的值。

許多執行URL 查詢字串自定義解析的庫特別容易受到攻擊,因為它們允許透過查詢字串指定深層物件結構,然後使用動態屬性修改來構建物件,例如?__proto__[test]=test?__proto__.test=test。一般來說,庫比應用程式程式碼更容易受到攻擊,因為它們無法允許有效鍵,並且通常需要使用動態屬性修改來實現通用性。

請注意,在 JSON 中,__proto__ 屬性只是一個普通的屬性名,因此解析像 {"__proto__": {"test": "value"}} 這樣的 JSON 有效負載只是建立了一個名為 __proto__ 的屬性的物件,並不會立即產生問題。然而,如果稍後在程式碼中,該物件透過 Object.assign()for...in 迴圈等合併到另一個物件中,那麼隱式屬性賦值操作將觸發 setter。通常,這並不會真正修改 Object.prototype,因為只有一級動態屬性訪問,但它會改變目標物件的原型。請注意,擴充套件語法不受此類攻擊的影響,因為擴充套件語法不會觸發 setter。

js
// Just an object with a property called `__proto__`
const options = JSON.parse('{"__proto__": {"test": "value"}}');
const withDefaults = Object.assign({ mode: "cors" }, options);
// In the process of merging `options`, we indirectly executed
// withDefaults.__proto__ = { test: "value" }, causing `withDefaults` to have
// a different prototype
console.log(withDefaults.test); // "value"

利用目標

要檢視原型汙染的效果,我們可以看看下面的 fetch() 呼叫如何完全改變。預設情況下,它是一個 GET 請求,沒有內容傳送到伺服器,但由於我們用兩個新的預設屬性汙染了 Object.prototype 物件,fetch() 呼叫現在轉換為一個 POST 請求,並且請求體現在包含伺服器的指令,例如將任意金額的錢轉移到任意地址

js
// Attacker indirectly causes the following pollution
Object.prototype.body = "action=transfer&amount=1337&to=1337-1337-1337-1337";
Object.prototype.method = "POST";

fetch("https://example.com", {
  mode: "cors",
});
// Promise {status: "pending", body: "action=transfer&amount=1337&to=1337-1337-1337-1337", method: "POST"}

// Any new object initialization is now modified to contain additional default properties
console.log({}.method); // "POST"
console.log({}.body); // "action=transfer&amount=1337&to=1337-1337-1337-1337"

另一個危險的汙染攻擊目標是 HTMLIframeElement.srcdoc 屬性,它指定了 <iframe> 元素的內容。透過覆蓋其值,理論上可能可以執行任意程式碼。

js
Object.prototype.srcdoc = "<script>alert(1)<\/script>";

配置物件,例如上述程式碼示例中fetch()RequestInit物件,或<iframe>的例項化,或淨化器(SanitizerConfig物件)的配置,是一些最敏感的物件,並且經常成為原型汙染攻擊的目標。資料物件也可能被汙染

js
function accessDashboard(user) {
  if (!user.isAdmin) {
    return new Response("Access denied", { status: 403 });
  }
  // show admin page
}

如果將Object.prototype.isAdmin設定為true,並且對於非管理員使用者,isAdmin屬性缺失而不是被明確設定為false,則所有使用者都將被視為管理員,從而導致完全繞過訪問控制。

防禦原型鏈汙染

防禦原型鏈汙染有兩條路線:避免可能導致原型修改的程式碼,以及避免訪問可能被汙染的屬性。以下部分將根據你的情況提供一些策略。

驗證使用者輸入

始終使用驗證器(例如ajvZod)驗證使用者輸入,以確保輸入資料結構包含具有適當型別的適當屬性。為了減輕原型汙染攻擊,透過在模式中將additionalProperties設定為false來拒絕不需要的屬性。使用模式還允許為缺失的屬性設定預設值,從而避免原型查詢。

你應該避免動態屬性修改(形式為obj[key] = value),除非你能夠驗證key的值。如果你處於這種情況,你可以在驗證中排除__proto__constructorprototype作為鍵。

Node.js 標誌 --disable-proto

如果你在 Node.js 環境中,可以使用 --disable-proto=MODE 選項停用 Object.prototype.__proto__,其中 MODE 可以是 delete(屬性完全移除),也可以是 throw(訪問該屬性會丟擲帶有程式碼 ERR_PROTO_ACCESS 的異常)。在非 Node 環境中,使用 delete Object.prototype.__proto__ 達到相同的效果。

這並不能完全保護你免受原型汙染(因為constructor.prototype仍然可用),但它確實消除了一個這樣的入口點。

鎖定內建物件

高安全性環境可能會實現一種稱為領域鎖定的防禦機制,它可以防止對內建物件的任何修改。一個例子是Hardened JavaScriptSES shim。這是基於Object.freeze()函式實現的,該函式可以防止擴充套件並使現有屬性不可寫和不可配置。凍結物件是 JavaScript 提供的最高完整性級別。另外,Object.seal()允許更改現有屬性,只要它們是可寫的,而Object.preventExtensions()則阻止向物件新增新屬性。

js
Object.freeze(Object.prototype);
const obj = {};
const key1 = "__proto__";
const key2 = "a";
obj[key1][key2] = 1; // fails silently in non-strict mode
obj.a; // undefined

然而,請注意,合法的原型修改可能會發生,通常是為了提供Polyfill實現。在非嚴格模式下,嘗試修改凍結物件會靜默失敗,而在嚴格模式下,它們會丟擲TypeError。為了允許 Polyfill,Polyfill 程式碼需要在凍結之前執行。

Object.freeze()的另一個注意事項是,它預設不提供深度凍結。如果你想要真正的不可變性,你需要遞迴地凍結每個屬性(示例)。像 SES 這樣的庫更可取,因為它會對所有內建物件進行“遍歷”,避免忘記凍結任何物件。

避免原型查詢

在訪問物件屬性的程式碼中,確保你知道該屬性存在於物件本身。在訪問或遍歷物件上的鍵時,你可以執行Object.hasOwn()檢查。

而不是

js
if (!user.isAdmin) {
  return new Response("Access denied", { status: 403 });
}

考慮

js
if (!Object.hasOwn(user, "isAdmin") || !user.isAdmin) {
  return new Response("Access denied", { status: 403 });
}

在迭代時,for...in 迴圈會遍歷原型。如果可能,將此類迴圈替換為 for...ofObject.keys(),以僅訪問自身鍵。

js
// Looks up the prototype
for (const key in payload) {
  doSomething(payload[key]);
}

// Only visits own keys
for (const key of Object.keys(payload)) {
  doSomething(payload[key]);
}

在函式中,明確設定預設引數,而不是讓它們未定義。這樣,可以使用預設引數值,而不是在原型鏈上進行潛在的查詢。而不是這樣

js
function doDangerousAction(options = {}) {
  if (!options.enableDangerousAction) {
    return;
  }
}

考慮這個

js
function doDangerousAction(options = { enableDangerousAction: false }) {
  if (!options.enableDangerousAction) {
    return;
  }
}

使用 null 原型建立 JavaScript 物件

空原型物件同時避免了原型汙染(因為__proto__constructor屬性不存在於物件上)並避免了原型上的查詢。它們可以透過Object.create(null)函式建立,或者在物件初始化器中使用{ __proto__: null }語法建立。

注意:物件初始化器中的 { __proto__: null } 原型 setter 語法是完全安全的,與 obj.__proto__ 訪問器屬性不同。

如果你需要將物件作為選項傳遞(例如,因為 fetch() 這樣的 API 要求你使用物件),請建立一個空原型物件。請注意,建立沒有原型的物件不是預設行為,因此每當例項化物件時,你需要記住顯式地建立一個空原型物件,而不是常規物件初始化器(const myObj = {})。

js
Object.prototype.method = "POST";

// Still sends a GET request, because the object has no prototype
fetch("https://example.com", {
  __proto__: null,
  mode: "cors",
});

如果你正在建立一個稍後將修改的物件(例如,透過obj[key] = value),請將其建立為 null 原型物件

js
const result = { __proto__: null };
const key1 = "__proto__";
const key2 = "a";
result[key1] ??= {};
result[key1][key2] = 1; // modifies result, not Object.prototype

改用 MapSet

當 JavaScript 物件用作臨時鍵值對時,請考慮改用 MapSet 物件。它們透過避免物件屬性修改或查詢來避免物件原型汙染。有關 Map 與 Object 的比較,請參閱 Map 文件。Map.prototype.get() 方法始終只返回 Map 中的條目。

js
// Assume Object got polluted somehow
Object.prototype.admin = true;

const config = new Map();
config.set("admin", false);

config.admin; // true
config.get("admin"); // false

防禦總結清單

建立物件時

  • 評估是否需要物件,或者 MapSet 是否是更好的選擇。
  • 將物件傳遞給其他函式(例如 FetchInitSanitizerConfig)時,要麼確保所有鍵都已定義,要麼使用空原型物件
  • 當建立以後會動態修改的物件(例如,透過obj[key] = value)時,也將其建立為 null 原型物件。

透過 URL 查詢字串、JSON 有效負載或函式引數接受使用者輸入時

  • 始終使用模式驗證器驗證使用者輸入。拒絕無法識別的屬性,併為缺失的屬性設定預設值。
  • 接收物件作為引數的函式應該確保所有預期的鍵都定義在物件本身上(透過設定預設值),或者在訪問之前首先檢查鍵是否存在於物件本身上(例如,透過Object.hasOwn())。
  • 優先使用 for...ofObject.keys(),而不是 for...in 迴圈。

對於內建和第三方物件

  • 考慮凍結內建物件和第三方物件,例如透過使用 SES shim。

執行時防禦

  • 在 Node.js 中使用 --disable-proto 停用 Object.prototype.__proto__
  • 在非 Node 環境中,使用 delete Object.prototype.__proto__

另見