深入分析 JavaScript 中的 Prototype Pollution (原型污染) 漏洞,并提供防御措施。

各位观众老爷,晚上好! 咳咳,今天咱们聊点刺激的——JavaScript 原型污染 (Prototype Pollution)。这玩意儿就像个隐藏的定时炸弹,平时你可能根本感觉不到它的存在,但一旦引爆,那可就热闹了,轻则页面崩坏,重则直接被人黑进服务器,想想都后背发凉。

咱们今天就来扒一扒这原型污染的底裤,看看它到底是怎么作妖的,以及咱们该如何“防狼”。

一、什么是原型污染?

别被“原型”这两个字唬住,其实概念很简单。在 JavaScript 里,每个对象都有一个原型 (prototype)。你可以把它想象成一个“老祖宗”,对象会继承老祖宗的属性和方法。

function Person(name) {
  this.name = name;
}

Person.prototype.greet = function() {
  console.log("Hello, my name is " + this.name);
};

const john = new Person("John");
john.greet(); // 输出: Hello, my name is John

console.log(john.__proto__ === Person.prototype); // 输出: true

在这个例子里,Person.prototype 就是 john 这个对象的原型。john 可以直接调用 greet 方法,因为它继承了原型上的方法。

原型污染,简单来说,就是 恶意修改了 JavaScript 对象的原型,导致所有基于该原型创建的对象都受到影响。 这就像往你家的自来水里投毒,全小区的人都得遭殃。

最容易被污染的就是 Object.prototype,因为 JavaScript 里几乎所有对象都直接或间接继承自它。 污染了 Object.prototype,基本上就等于污染了整个 JavaScript 世界。

二、原型污染是怎么发生的?

原型污染通常是因为代码中存在一些不安全的、允许修改对象属性的操作,而且这些操作的属性名又是可以由用户控制的。 听起来有点绕,咱们举几个栗子:

1. 深度合并 (Deep Merge)

深度合并是一种常见的操作,用于将多个对象合并成一个对象,如果遇到嵌套的对象,会递归地合并。但如果深度合并的逻辑写得不够严谨,就可能导致原型污染。

function deepMerge(target, source) {
  for (const key in source) {
    if (typeof source[key] === 'object' && source[key] !== null && !Array.isArray(source[key])) {
      if (!target[key] || typeof target[key] !== 'object') {
        target[key] = {};
      }
      deepMerge(target[key], source[key]);
    } else {
      target[key] = source[key];
    }
  }
  return target;
}

const obj = {};
const maliciousPayload = JSON.parse('{"__proto__":{"isAdmin":true}}');

deepMerge(obj, maliciousPayload);

console.log({}.isAdmin); // 输出: true

在这个例子里,maliciousPayload 包含一个 __proto__ 属性,它的值又是一个对象,这个对象设置了 isAdmintruedeepMerge 函数没有对 __proto__ 属性进行过滤,导致它被合并到 Object.prototype 上,从而污染了所有对象。

为啥是 __proto__

__proto__ 是一个非标准的属性,用于访问对象的原型。虽然它已经被标准化,但它仍然是原型污染的主要入口点。 在一些老的浏览器或环境中,可能还会使用 constructor.prototype 来进行原型污染。

2. 点号赋值 (Dot Notation)

如果属性名可以由用户控制,并且直接使用点号赋值,也可能导致原型污染。

function setProperty(obj, key, value) {
  obj[key] = value;
}

const obj = {};
const key = '__proto__.isAdmin';
const value = true;

setProperty(obj, key, value);

console.log({}.isAdmin); // 输出: true

在这个例子里,setProperty 函数直接使用 obj[key] = value 来设置属性,如果 key__proto__.isAdmin,就会导致原型污染。

3. 其他情况

除了深度合并和点号赋值,还有一些其他情况也可能导致原型污染,例如:

  • 反序列化不安全的 JSON: 如果 JSON 字符串中包含 __proto__ 属性,并且没有进行过滤,就可能导致原型污染。
  • 模板引擎: 如果模板引擎允许用户控制属性名,并且没有进行安全检查,也可能导致原型污染。
  • 第三方库: 一些第三方库可能存在原型污染漏洞,如果使用了这些库,也可能受到影响。

三、原型污染的危害

原型污染的危害可大可小,取决于污染的内容和应用程序的逻辑。

  • 拒绝服务 (Denial of Service, DoS): 可以通过污染原型,导致应用程序抛出异常或进入死循环,从而拒绝服务。
  • 权限提升 (Privilege Escalation): 可以通过污染原型,修改应用程序的权限控制逻辑,从而获得更高的权限。
  • 远程代码执行 (Remote Code Execution, RCE): 在某些情况下,可以通过污染原型,执行任意代码。 例如,如果应用程序使用了 Function.prototype.applyFunction.prototype.call,并且参数可以由用户控制,就可能通过原型污染来执行任意代码。
  • 信息泄露 (Information Disclosure): 可以通过污染原型,访问或修改应用程序的敏感数据。

举个权限提升的栗子:

假设我们有一个简单的权限验证系统:

const user = {
  username: "guest",
  isAdmin: false
};

function checkAdmin(user) {
  if (user.isAdmin) {
    console.log("You are an admin!");
  } else {
    console.log("You are not an admin.");
  }
}

checkAdmin(user); // 输出: You are not an admin.

// 恶意攻击者进行原型污染
Object.prototype.isAdmin = true;

checkAdmin(user); // 输出: You are an admin!

由于 Object.prototype 被污染,所有对象现在都拥有 isAdmin 属性,并且值为 true。 这就导致了权限提升,原本不是管理员的用户现在也被认为是管理员了。

四、如何防御原型污染?

防止原型污染是一个需要重视的问题,我们需要从多个方面入手。

1. 避免不安全的属性赋值

  • 使用 Object.create(null) 创建对象: Object.create(null) 创建的对象没有原型,因此不会受到原型污染的影响。

    const obj = Object.create(null);
    obj.__proto__ = { isAdmin: true }; // 无效
    console.log(obj.isAdmin); // 输出: undefined

    注意: 使用 Object.create(null) 创建的对象没有继承任何属性和方法,包括 toStringvalueOf 等,因此在使用时需要小心。

  • 使用 Object.freeze() 冻结对象: Object.freeze() 可以冻结对象,使其无法被修改。

    const obj = { name: "John" };
    Object.freeze(obj);
    obj.name = "Jane"; // 静默失败 (在严格模式下会抛出 TypeError)
    console.log(obj.name); // 输出: John

    注意: Object.freeze() 只能冻结对象的直接属性,如果对象包含嵌套的对象,则嵌套的对象仍然可以被修改。

  • 使用 Object.seal() 封闭对象: Object.seal() 可以封闭对象,使其无法添加新的属性,但可以修改已有的属性。

    const obj = { name: "John" };
    Object.seal(obj);
    obj.name = "Jane"; // 有效
    obj.age = 30; // 静默失败 (在严格模式下会抛出 TypeError)
    console.log(obj.name); // 输出: Jane
    console.log(obj.age); // 输出: undefined

    注意: Object.seal() 只能封闭对象的直接属性,如果对象包含嵌套的对象,则嵌套的对象仍然可以被修改。

  • 使用 Object.defineProperty() 定义属性: Object.defineProperty() 可以精确地控制属性的行为,例如设置 writableconfigurableenumerable 标志。

    const obj = {};
    Object.defineProperty(obj, "isAdmin", {
      value: false,
      writable: false,
      configurable: false,
      enumerable: true
    });
    
    obj.isAdmin = true; // 静默失败 (在严格模式下会抛出 TypeError)
    console.log(obj.isAdmin); // 输出: false
  • 避免使用用户可控的属性名进行赋值: 尽量避免使用 obj[key] = value 这种方式进行赋值,特别是当 key 可以由用户控制时。 如果必须使用,需要对 key 进行严格的验证和过滤。

2. 过滤敏感属性

  • 过滤 __proto__constructorprototype 属性: 在处理用户输入或外部数据时,需要对 __proto__constructorprototype 属性进行过滤,防止它们被用于修改原型。

    function sanitize(obj) {
      const newObj = {};
      for (const key in obj) {
        if (key !== '__proto__' && key !== 'constructor' && key !== 'prototype') {
          newObj[key] = obj[key];
        }
      }
      return newObj;
    }
    
    const maliciousPayload = JSON.parse('{"__proto__":{"isAdmin":true}, "name": "John"}');
    const sanitizedPayload = sanitize(maliciousPayload);
    
    console.log(sanitizedPayload.__proto__); // 输出: undefined
    console.log(sanitizedPayload.name); // 输出: John
  • 使用 Object.hasOwn()hasOwnProperty() 检查属性是否是对象自身的属性: 在访问属性之前,可以使用 Object.hasOwn()hasOwnProperty() 检查属性是否是对象自身的属性,而不是从原型链上继承的。

    const obj = { name: "John" };
    Object.prototype.isAdmin = true;
    
    console.log(Object.hasOwn(obj, "name")); // 输出: true
    console.log(Object.hasOwn(obj, "isAdmin")); // 输出: false
    
    console.log(obj.hasOwnProperty("name")); // 输出: true
    console.log(obj.hasOwnProperty("isAdmin")); // 输出: false

    注意: Object.hasOwn() 是 ES2022 新增的方法,如果需要兼容旧版本的浏览器,可以使用 hasOwnProperty()

3. 使用安全的深度合并库

如果需要使用深度合并,建议使用一些经过安全审计的深度合并库,例如 lodash.mergemerge-deep。 这些库通常会对 __proto__ 属性进行过滤,防止原型污染。

4. 使用静态代码分析工具

可以使用一些静态代码分析工具,例如 ESLint 或 SonarQube,来检测代码中是否存在潜在的原型污染漏洞。 这些工具可以根据预定义的规则,自动扫描代码,并报告可能存在问题的代码片段。

5. 保持第三方库更新

及时更新使用的第三方库,以修复已知的原型污染漏洞。 许多第三方库都会定期发布安全更新,修复已知的漏洞。

6. 使用内容安全策略 (Content Security Policy, CSP)

可以使用 CSP 来限制 JavaScript 代码的执行,从而减少原型污染的风险。 CSP 是一种安全策略,可以控制浏览器可以加载哪些资源,例如 JavaScript、CSS、图片等。

7. 输入验证和输出编码

对用户输入进行严格的验证,防止恶意输入。 对输出进行编码,防止跨站脚本攻击 (Cross-Site Scripting, XSS),XSS攻击也可能间接导致原型污染。

8. 定期进行安全审计

定期对应用程序进行安全审计,检查是否存在潜在的原型污染漏洞。 可以使用一些专业的安全审计工具,或者请专业的安全专家进行审计。

五、一些额外的 Tips

  • 养成良好的编码习惯: 编写代码时要时刻注意安全性,避免使用不安全的 API 和操作。
  • 了解 JavaScript 原型链: 深入了解 JavaScript 原型链的机制,可以更好地理解原型污染的原理,从而更好地预防它。
  • 关注安全社区: 关注安全社区的动态,及时了解最新的原型污染漏洞和防御方法。

六、总结

原型污染是一种非常隐蔽和危险的漏洞,它可以导致各种各样的安全问题。 为了防止原型污染,我们需要从多个方面入手,包括避免不安全的属性赋值、过滤敏感属性、使用安全的深度合并库、使用静态代码分析工具、保持第三方库更新、使用内容安全策略、输入验证和输出编码,以及定期进行安全审计。

希望通过今天的讲解,大家能够对原型污染有更深入的了解,并且能够在实际开发中有效地预防它。

好了,今天的讲座就到这里,谢谢大家! 散会!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注