← 中文资源

简体中文版本

HTTP 请求走私

HTTP 请求走私利用前后两级设备对“请求边界”的解析分歧,让攻击者把隐藏请求前缀注入他人连接,可劫持会话或毒化缓存。

阅读英文原文

概念定义

请求走私是一类前后端失步攻击:当代理与源站对 Content-Length 与 Transfer-Encoding 等成帧信号解析不一致时,一个请求的多余字节会被当作下一个请求的开头。被走私的前缀可劫持会话、毒化缓存或绕过内部访问控制。

工作机制

经典变体滥用冲突的长度头、重复头部或畸形块长度。HTTP/2 到 HTTP/1.1 的翻译层引入新向量:内容长度本由帧隐含,翻译时重建反而制造歧义。走私前缀可携带完整请求,后端会把它归属到受害者的连接上。

使用场景

任何多于一个 HTTP 跳点的部署都应做成帧规范化:拒绝冲突或重复的长度头、强制单一 Transfer-Encoding、丢弃驱动失步的头部名。CDN、API 网关、企业出口代理与 WAF 都在此列,且多个代理组合存在已公开的失步案例。

常见误区

走私者往往只是普通用户,失步存在于你自己的设备之间,而不是攻击者发出畸形请求这么简单。症状常很微弱:诡异的 400、幻影 405、或陌生会话行为异常。经走私响应造成的缓存污染可在攻击结束后持续很久。修复应落在规范化中间层,而非逐个补丁。

代码示例

export function parseContentDisposition(header) {
  if (typeof header !== "string" || header.trim() === "") {
    throw new TypeError("header must be a non-empty string");
  }
  const segments = splitParameters(header);
  const type = segments.shift().trim().toLowerCase();
  if (type !== "inline" && type !== "attachment") {
    throw new Error(`unsupported disposition type "${type}"`);
  }
  const parameters = new Map();
  for (const segment of segments) {
    const equals = segment.indexOf("=");
    if (equals === -1) continue;
    const name = segment.slice(0, equals).trim().toLowerCase();
    if (name === "") continue;
    parameters.set(name, unquote(segment.slice(equals + 1).trim()));
  }
  return { type, filename: resolveFilename(parameters), parameters };
}

function splitParameters(header) {
  const segments = [];
  let current = "";
  let quoted = false;
  for (const character of header) {
    if (character === '"') quoted = !quoted;
    if (character === ";" && !quoted) {
      segments.push(current);
      current = "";
      continue;
    }
    current += character;
  }
  segments.push(current);
  return segments;
}

function unquote(value) {
  if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) {
    return value.slice(1, -1).replace(/\\(.)/g, "$1");
  }
  return value;
}

function resolveFilename(parameters) {
  const extended = parameters.get("filename*");
  if (extended !== undefined) {
    const match = /^([\w-]+)'([\w-]*)'(.+)$/.exec(extended);
    if (match !== null) {
      try {
        return sanitize(decodeURIComponent(match[3]));
      } catch {
        return sanitize(extended);
      }
    }
  }
  const plain = parameters.get("filename");
  return plain === undefined ? null : sanitize(plain);
}

function sanitize(filename) {
  return filename.split(/[\\/]/).pop() || null;
}

console.log(parseContentDisposition('attachment; filename="report.pdf"'));
console.log(parseContentDisposition("attachment; filename*=UTF-8''%E4%B8%AD%E6%96%87.pdf"));
console.log(parseContentDisposition('inline; filename="../../etc/passwd"'));
export class LatestRequestGate {
  private active: AbortController | null = null;
  private sequence = 0;

  private begin(): () => boolean {
    this.sequence += 1;
    const ticket = this.sequence;
    return () => ticket === this.sequence;
  }

  async run<T>(task: (signal: AbortSignal) => Promise<T>): Promise<T | undefined> {
    if (typeof task !== "function") throw new TypeError("task must be a function");
    const controller = new AbortController();
    const isCurrent = this.begin();
    if (this.active) this.active.abort();
    this.active = controller;
    try {
      const result = await task(controller.signal);
      return isCurrent() ? result : undefined;
    } catch (error) {
      if (controller.signal.aborted) return undefined;
      throw error;
    } finally {
      if (isCurrent()) this.active = null;
    }
  }

  abort(): void {
    if (this.active) {
      this.active.abort();
      this.active = null;
    }
  }
}