← 中文资源

简体中文版本

原型污染

原型污染经由不安全的合并与按路径赋值,把属性写进 JavaScript 对象原型,让全进程对象继承注入值,进而触发逻辑注入、认证绕过乃至 RCE。

阅读英文原文

概念定义

JavaScript 属性查找沿原型链上行。攻击者输入若能写进共享原型,之后进程里的每个对象都会继承该属性——安全检查从任意对象上读到的布尔值,突然变成了攻击者给的。

工作机制

污染入口是把输入键当对象路径用的工具:两份 JSON 的深合并、把 a[b][proto][x] 展开的查询串解析器、set(obj, user.path, value) 助手。危险键是路径任意层出现的 proto、constructor、prototype。随后 gadget 利用负责找到以安全相关方式读取被污染属性的代码。

使用场景

审计一切“不可信 JSON 深合并进默认配置、查询串反序列化成对象、状态路径来自用户”的地方。混合信任数据优先用结构化克隆与显式字段拷贝,而不是通用深合并。

常见误区

冻结 Object.prototype 不是终点——真实库会经 constructor.prototype 链写入,浅冻结会漏。不只深合并有洞:路径赋值器、查询串解析器都发过此洞。Node 侧没有 DOM 也有 gadget:模板引擎与子进程选项注入即可升级危害。补丁按库发布,依赖树每次变更都要重审。

代码示例

const BLOCKED = new Set(["__proto__", "prototype", "constructor"]);

export function parsePath(path) {
  if (typeof path !== "string" || path.trim() === "") {
    throw new TypeError("path must be a non-empty string");
  }
  const normalized = path.replace(/\[(\d+)\]/g, ".$1");
  if (/[\[\]]/.test(normalized)) throw new SyntaxError("unsupported bracket syntax");
  if (normalized.startsWith(".") || normalized.endsWith(".") || normalized.includes("..")) {
    throw new SyntaxError("path contains an empty segment");
  }
  return normalized.split(".");
}

function assertSafe(token) {
  if (BLOCKED.has(token)) throw new TypeError("unsafe path segment: " + token);
}

function isPlainObject(value) {
  if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
  const prototype = Object.getPrototypeOf(value);
  return prototype === Object.prototype || prototype === null;
}

export function getPath(source, path, fallback) {
  let current = source;
  for (const token of parsePath(path)) {
    assertSafe(token);
    if (current === null || (typeof current !== "object" && typeof current !== "function")) {
      return fallback;
    }
    if (!Object.prototype.hasOwnProperty.call(current, token)) return fallback;
    current = current[token];
  }
  return current === undefined ? fallback : current;
}

export function setPath(source, path, value) {
  const tokens = parsePath(path);
  for (const token of tokens) assertSafe(token);
  const root = Array.isArray(source)
    ? [...source]
    : isPlainObject(source)
      ? { ...source }
      : {};
  let current = root;

  for (let index = 0; index < tokens.length - 1; index += 1) {
    const token = tokens[index];
    const nextToken = tokens[index + 1];
    if (Array.isArray(current) && !/^\d+$/.test(token)) {
      throw new TypeError("array path segments must be numeric");
    }
    const existing = Object.prototype.hasOwnProperty.call(current, token)
      ? current[token]
      : undefined;
    let next;
    if (Array.isArray(existing)) next = [...existing];
    else if (isPlainObject(existing)) next = { ...existing };
    else next = /^\d+$/.test(nextToken) ? [] : {};
    current[token] = next;
    current = next;
  }

  const last = tokens[tokens.length - 1];
  if (Array.isArray(current)) {
    if (!/^\d+$/.test(last)) throw new TypeError("array path segments must be numeric");
    current[Number(last)] = value;
  } else {
    current[last] = value;
  }
  return root;
}
export function createFlagSet(names) {
  if (!Array.isArray(names) || names.length === 0) {
    throw new TypeError("names must be a non-empty array");
  }
  if (names.length > 30) throw new RangeError("at most 30 flags are supported");
  const bits = new Map();
  const all = [];
  for (const name of names) {
    if (typeof name !== "string" || name.trim() === "") {
      throw new TypeError("flag names must be non-empty strings");
    }
    const normalized = name.trim();
    if (bits.has(normalized)) throw new Error(`duplicate flag "${normalized}"`);
    bits.set(normalized, 1 << bits.size);
    all.push(normalized);
  }
  const resolve = (name) => {
    const bit = bits.get(name);
    if (bit === undefined) throw new Error(`unknown flag "${name}"`);
    return bit;
  };
  return {
    names: () => [...all],
    mask(...selected) {
      return selected.reduce((mask, name) => mask | resolve(name), 0);
    },
    has(mask, name) {
      return (mask & resolve(name)) !== 0;
    },
    add(mask, name) {
      return mask | resolve(name);
    },
    remove(mask, name) {
      return mask & ~resolve(name);
    },
    toggle(mask, name) {
      return mask ^ resolve(name);
    },
    toNames(mask) {
      return all.filter((name) => (mask & resolve(name)) !== 0);
    },
  };
}

const permissions = createFlagSet(["read", "write", "admin"]);
let mask = permissions.mask("read", "write");
console.log(mask, permissions.toNames(mask));
mask = permissions.remove(mask, "write");
mask = permissions.toggle(mask, "admin");
console.log(permissions.has(mask, "write"), permissions.toNames(mask));