← 中文资源

简体中文版本

不安全的反序列化

不安全的反序列化把序列化对象还原成活的代码与状态:语言运行时的 gadget 链把构造载荷转成远程代码执行或逻辑滥用。

阅读英文原文

概念定义

序列化压平活对象,反序列化重建它们——构造器、属性 setter、自定义钩子都会执行。字节流受攻击者影响时,攻击者就选定了哪些类复活、以什么状态复活,而充满副作用的类库随即变成 gadget 目录。

工作机制

载荷点名那些实例化即产生效果的类:写文件、起进程、改静态状态。gadget 链把它们组合成任意执行。检测困难源于 blob 不透明;预防靠“纯数据格式”如无类型标签的 JSON,或带签名与版本的信封加严格模式校验。

使用场景

一切序列化对象跨越信任边界的地方:会话、缓存条目、队列消息、RPC 体。应迁移到带模式校验的显式 DTO,把对象图序列化器严格限制在单进程内。

常见误区

给 blob 签名只保完整性不保安全性——密钥泄露或内部来源仍可反序列化成 gadget。“只有 Java 有洞”是错觉:Python pickle、PHP 对象注入、.NET BinaryFormatter、带标签的 YAML 都在加载时执行。运行时过滤(Java look-ahead)减少可达类但需随依赖演进维护;压缩加密只让审查者看不清,不消除 gadget 风险。

代码示例

from pathlib import Path


class UnsafePathError(ValueError):
    pass


def safe_join(root: str | Path, user_path: str) -> Path:
    if not isinstance(user_path, str) or not user_path.strip():
        raise UnsafePathError("path must be a non-empty string")
    if "\x00" in user_path:
        raise UnsafePathError("path must not contain NUL bytes")

    candidate = Path(user_path)
    if candidate.is_absolute() or candidate.drive:
        raise UnsafePathError("absolute paths are not allowed")

    base = Path(root).resolve()
    target = (base / candidate).resolve()
    try:
        target.relative_to(base)
    except ValueError as error:
        raise UnsafePathError(f"path escapes root: {user_path!r}") from error
    return target


def main() -> None:
    root = Path.cwd() / "public"
    for value in ["reports/2026/q1.csv", "../secrets.env", "/etc/passwd", "a/../../b"]:
        try:
            print(value, "->", safe_join(root, value))
        except UnsafePathError as error:
            print(value, "-> rejected:", error)


if __name__ == "__main__":
    main()
type PathTree = Map<string, PathTree>;

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

export function omitDeep<Value>(value: Value, paths: readonly string[]): Value {
  if (!Array.isArray(paths)) throw new TypeError("paths must be an array");
  if (paths.length === 0) return value;
  const tree: PathTree = new Map();
  for (const path of paths) {
    if (typeof path !== "string" || path.trim() === "") {
      throw new TypeError("paths must be non-empty strings");
    }
    const segments = path.split(".").map((segment) => segment.trim());
    if (segments.some((segment) => segment === "")) throw new Error(`invalid path "${path}"`);
    for (const segment of segments) {
      if (FORBIDDEN_KEYS.has(segment)) throw new Error(`path "${path}" contains a forbidden key`);
    }
    insert(tree, segments);
  }
  return clone(value, tree) as Value;
}

function insert(tree: PathTree, segments: readonly string[]): void {
  let node = tree;
  for (const segment of segments) {
    let child = node.get(segment);
    if (child === undefined) {
      child = new Map();
      node.set(segment, child);
    }
    node = child;
  }
}

function clone(value: unknown, tree: PathTree): unknown {
  if (Array.isArray(value)) {
    return value.map((item, index) => {
      const child = tree.get(String(index));
      if (child !== undefined && child.size === 0) return undefined;
      return clone(item, child ?? tree);
    });
  }
  if (value !== null && typeof value === "object") {
    const prototype = Object.getPrototypeOf(value);
    if (prototype !== Object.prototype && prototype !== null) {
      throw new TypeError("omitDeep only supports plain objects and arrays");
    }
    const output: Record<string, unknown> = {};
    for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
      const child = tree.get(key);
      if (child !== undefined && child.size === 0) continue;
      output[key] = clone(item, child ?? new Map());
    }
    return output;
  }
  return value;
}

const source = {
  id: 1,
  password: "secret",
  profile: { email: "[email protected]", token: "abc", nested: { keep: true } },
  items: [{ id: 1, token: "a" }, { id: 2, token: "b" }],
};
const sanitized = omitDeep(source, ["password", "profile.token", "items.token"]);
console.log(JSON.stringify(sanitized));
console.log(JSON.stringify(source));