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__

另见