← 中文资源

简体中文版本

Web 缓存投毒

Web 缓存投毒利用“缓存键”与“响应实际依赖”的差集:提供不被计入键的输入(头、参数)改变响应,让正常 URL 的下一位访客拿到恶意内容。

阅读英文原文

概念定义

缓存用键判定两个请求相同:通常是方法、路径与部分头。凡“响应依赖它、键却忽略它”的输入都是攻击杠杆。攻击者设置投毒输入、让缓存把恶意响应存到干净键下,然后扬长而去——之后所有请求干净 URL 的用户都被投毒。

工作机制

寻找“非键输入且影响响应”的组合:由 Host 派生的重定向、错误页里反射的头、页面里内嵌的开放重定向 URL。带着恶意输入请求一次,缓存按干净键存储结果;受害者的页面里从此出现攻击者控制的脚本、重定向或内容。

使用场景

加固一切“响应内容派生自请求头或未识别参数”的端点:校验并钉死派生值、在边缘剥除非键输入、配置好 Vary 与缓存键让每个响应维度都被键控。

常见误区

键的规范化因产品而异:一个 CDN 的非键输入在另一个是键控的——必须测试实际链路。HTTPS 拦不住它,攻击发生在 TLS 边界内的代理层;短 TTL 也只是心理安慰,边缘规模下几秒就能覆盖大量用户。相关变体还有 fat GET 与请求走私衍生的缓存欺骗;应监控边缘存储响应中的异常主机名与脚本。

代码示例

export type DirectiveValue = readonly string[];

export interface CspOptions {
  readonly directives?: Readonly<Record<string, DirectiveValue>>;
  readonly reportOnly?: boolean;
  readonly nonce?: string;
}

const BASE_DIRECTIVES: Record<string, readonly string[]> = {
  "default-src": ["'self'"],
  "script-src": ["'self'"],
  "style-src": ["'self'"],
  "img-src": ["'self'", "data:"],
  "object-src": ["'none'"],
  "base-uri": ["'self'"],
  "frame-ancestors": ["'none'"],
};

export function createNonce(): string {
  const bytes = new Uint8Array(16);
  globalThis.crypto.getRandomValues(bytes);
  let binary = "";
  for (const byte of bytes) binary += String.fromCharCode(byte);
  return btoa(binary);
}

export function buildCsp(options: CspOptions = {}): { name: string; value: string } {
  const directives: Record<string, readonly string[]> = { ...BASE_DIRECTIVES };
  if (options.nonce !== undefined) {
    if (!/^[A-Za-z0-9+/]+={0,2}$/.test(options.nonce)) throw new Error("nonce must be base64");
    directives["script-src"] = [...(directives["script-src"] ?? []), `'nonce-${options.nonce}'`];
  }
  for (const [name, values] of Object.entries(options.directives ?? {})) {
    if (!/^[a-z][a-z0-9-]*$/.test(name)) throw new Error(`invalid directive name "${name}"`);
    for (const value of values) {
      if (typeof value !== "string" || /[;\s]/.test(value)) {
        throw new Error(`invalid value for "${name}"`);
      }
    }
    directives[name] = values;
  }
  const serialized = Object.entries(directives)
    .filter(([, values]) => values.length > 0)
    .map(([name, values]) => `${name} ${values.join(" ")}`)
    .join("; ");
  return {
    name: options.reportOnly === true ? "Content-Security-Policy-Report-Only" : "Content-Security-Policy",
    value: serialized,
  };
}

const header = buildCsp({
  nonce: createNonce(),
  directives: {
    "connect-src": ["'self'", "https://api.example.com"],
    "img-src": ["'self'", "data:", "https://cdn.example.com"],
  },
});
console.log(header.name);
console.log(header.value);
console.log(buildCsp({ reportOnly: true }).name);
function assertSecret(secret: string): void {
  if (typeof secret !== "string" || secret.length === 0) {
    throw new TypeError("secret must be a non-empty string");
  }
}

export async function deriveAesKey(
  secret: string,
  salt: Uint8Array,
  info: string,
): Promise<CryptoKey> {
  assertSecret(secret);
  if (!(salt instanceof Uint8Array)) throw new TypeError("salt must be a Uint8Array");
  if (typeof info !== "string") throw new TypeError("info must be a string");
  const material = await crypto.subtle.importKey(
    "raw",
    new TextEncoder().encode(secret),
    "HKDF",
    false,
    ["deriveKey"],
  );
  return crypto.subtle.deriveKey(
    { name: "HKDF", hash: "SHA-256", salt, info: new TextEncoder().encode(info) },
    material,
    { name: "AES-GCM", length: 256 },
    false,
    ["encrypt", "decrypt"],
  );
}

export async function deriveBytes(
  secret: string,
  salt: Uint8Array,
  info: string,
  length = 32,
): Promise<Uint8Array> {
  assertSecret(secret);
  if (!(salt instanceof Uint8Array)) throw new TypeError("salt must be a Uint8Array");
  if (typeof info !== "string") throw new TypeError("info must be a string");
  if (!Number.isInteger(length) || length < 1 || length > 255) {
    throw new RangeError("length must be an integer between 1 and 255");
  }
  const material = await crypto.subtle.importKey(
    "raw",
    new TextEncoder().encode(secret),
    "HKDF",
    false,
    ["deriveBits"],
  );
  const bits = await crypto.subtle.deriveBits(
    { name: "HKDF", hash: "SHA-256", salt, info: new TextEncoder().encode(info) },
    material,
    length * 8,
  );
  return new Uint8Array(bits);
}