← 中文资源

简体中文版本

Host 头注入

Host 头注入利用服务器用 Host 头构造 URL——密码重置链接、重定向、缓存键——而不加校验,产出指向攻击者域的投毒链接与缓存条目。

阅读英文原文

概念定义

Host 头是客户端输入。应用一旦把它当配置——当权威域名——它构造的每个 URL 都继承攻击者的控制。最具破坏性的流程是密码重置:受害者点开的链接把重置令牌送进攻击者域名。

工作机制

攻击者带被操纵的 Host 或注入的 X-Forwarded-Host 请求重置流程,邮件渲染的链接便指向该主机。加固方式:基础 URL 一律来自受验证的配置;Host 按环境精确白名单校验;除非代理链逐跳可信,边缘应清理或覆写转发头。

使用场景

审计邮件渲染、重定向生成、OAuth 回调构造、以及一切从请求上下文拼绝对 URL 的代码。按主机名路由的多租户应用要区分“租户查找”(合法)与“URL 构造”(不合法)。

常见误区

“代理会安全地设置 Host”是错觉:除非显式覆写,代理转发客户端所发内容;X-Forwarded-Host 更需显式信任。后缀校验挡不住 evil.example.com。重复 Host 头与绝对 URI 请求目标会在代理与源站间制造解析分歧——那是走私家族;按 Host 变化的缓存若白名单缺失,等于给攻击者按主机名的投毒键。

代码示例

import re
from pathlib import PurePosixPath
from urllib.parse import unquote

NAME_STAR = re.compile(r"filename\*\s*=\s*([^;]+)", re.IGNORECASE)
NAME = re.compile(r'filename\s*=\s*(?:"([^"]*)"|([^;]+))', re.IGNORECASE)


def safe_basename(value: str) -> str | None:
    candidate = PurePosixPath(value.replace("\\", "/")).name.strip()
    if candidate in {"", ".", ".."} or "\x00" in candidate:
        return None
    return candidate


def attachment_filename(header: str) -> str | None:
    match = NAME_STAR.search(header)
    if match:
        value = match.group(1).strip().strip('"')
        charset, _, remainder = value.partition("'")
        _, _, encoded = remainder.partition("'")
        try:
            decoded = unquote(
                encoded or remainder,
                encoding=charset or "utf-8",
                errors="strict",
            )
        except (LookupError, UnicodeDecodeError):
            decoded = unquote(encoded or remainder)
        return safe_basename(decoded)
    match = NAME.search(header)
    if not match:
        return None
    value = match.group(1) if match.group(1) is not None else match.group(2)
    return safe_basename(unquote(value.strip()))


def main() -> None:
    headers = [
        'attachment; filename="report final.pdf"',
        "attachment; filename=../../etc/passwd",
        "attachment; filename*=UTF-8''%E5%B9%B4%E6%8A%A5.pdf",
        'attachment; filename=".."',
    ]
    for header in headers:
        print(attachment_filename(header))


if __name__ == "__main__":
    main()
import { DatabaseSync } from 'node:sqlite';

const db = new DatabaseSync(':memory:');
db.exec('CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT)');
db.prepare('INSERT INTO users (email) VALUES (?)').run('[email protected]');

function findEmails(ids) {
  if (ids.length === 0) return [];
  if (ids.some((id) => !Number.isInteger(id))) {
    throw new TypeError('ids must be integers');
  }

  const placeholders = ids.map(() => '?').join(', ');
  const stmt = db.prepare(
    'SELECT id, email FROM users WHERE id IN (' + placeholders + ')',
  );
  return stmt.all(...ids);
}

try {
  // 恶意输入只是参数值,永远不会被解析成 SQL 语法
  db.prepare('SELECT * FROM users WHERE email = ?').all("x' OR '1'='1");
} catch (err) {
  console.log('injection attempt contained:', err instanceof Error);
}

const rows = findEmails([1, 2, 3]);
console.log('matched rows:', rows.length);
db.close();