← 中文资源

简体中文版本

DNS 重绑定

DNS 重绑定让同一主机名先解析到公网 IP、TTL 过后再解析到内网地址,公共网页借浏览器绕过同源策略触达内网服务。

阅读英文原文

概念定义

同源策略按主机名而不是 IP 判定。若主机名随时间映射到不同 IP,一个源就横跨两个网络——而攻击者控制 DNS 区域与 TTL,可以按计划改变映射。

工作机制

页面先从 attacker.example 加载:首次应答来自真正的公网服务器并下发脚本;短 TTL 过后同一域名解析到 192.168.x.x,脚本继续向“同源”发 fetch,浏览器欣然把请求送往内网设备,并因源从未改变而能读取响应。

使用场景

防御核心是给每个内网服务做认证与授权——网络位置不是身份;或在解析器侧阻断“公网域名解析到私网段”,并强制所有客户端走该解析器。家庭与 OT 网络是惯常受害者,路由器出厂即带无认证面板。

常见误区

“内网服务浏览器够不着”不成立:浏览器本身就是代理,请求从边界内发出。防火墙也拦不住——流量只是普通出站 DNS 加浏览器 HTTP。浏览器的 Local Network Access 类防护在逐步铺开但各引擎进度不一;内网服务上 TLS 能阻止脚本读响应,却挡不住盲发。在解析器策略里钉住内网主机名,胜过逐个修补应用。

代码示例

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);
}
export async function sha256Hex(input: string | Uint8Array): Promise<string> {
  if (typeof input !== "string" && !(input instanceof Uint8Array)) {
    throw new TypeError("input must be a string or Uint8Array");
  }
  const bytes = typeof input === "string" ? new TextEncoder().encode(input) : input;
  const digest = await crypto.subtle.digest("SHA-256", bytes);
  return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
}

export async function verifySha256(input: string | Uint8Array, expectedHex: string): Promise<boolean> {
  if (typeof expectedHex !== "string" || !/^[0-9a-f]{64}$/i.test(expectedHex)) {
    throw new RangeError("expectedHex must contain 64 hexadecimal characters");
  }
  const actual = await sha256Hex(input);
  return actual === expectedHex.toLowerCase();
}