[P0][CR-S1] 联机中继:单个畸形帧头即可让进程崩溃(帧校验、上限、异常隔离缺失) #517

Closed
opened 2026-09-29 06:43:53 +00:00 by troytt · 1 comment
Owner

来源:#516|优先级 P0|评审编号 S1(含 S2、S6 的中继部分)|基线 e475c2e

问题描述

联机唯一的服务端组件是 scripts/net-relay.ts:191 行,手写 RFC 6455 帧编解码。任何能连上中继的客户端,只要发一个 10 字节的帧头,就能让整个 Node 进程以 exit 1 退出,所有玩家同时断线(已复现)。

除此之外,中继对帧格式、连接数、缓冲和背压都没有约束,也不分配身份、不分房间。目前它只监听 127.0.0.1;一旦为了远程联机放到反代或隧道后面,这些问题会直接暴露。

证据

  • 64 位帧长超过 1MB 时,decodeFrames 直接 throw new Error('frame too large'):net-relay.ts:99。
  • 它在 socket.on('data') 回调里被同步调用(net-relay.ts:150-155),回调外没有 try/catch。入口 net-server.ts:12-22 也没有任何进程级处理,异常未被捕获,进程就退出了。
  • 同类问题:
    • 不校验 MASK 位:RFC 6455 §5.1 要求客户端帧必须 mask,这里没有检查。
    • 不处理 FIN/续帧:分片消息只转发第一片,续帧(opcode 0)被静默丢弃(net-relay.ts:158-160)。
    • TEXT 帧被改成 BINARY 转发(net-relay.ts:160-161)。客户端"只收二进制"的防线(transport.ts:362-364)因此失效。
    • 单帧上限与客户端不一致:中继允许 ≤1MB 的帧,客户端默认上限是 4096B(transport.ts:49),超限时直接在 message 监听器里 throw(transport.ts:383-384)。一个大帧会被原样广播,让所有客户端同时报错。
    • 广播不做背压:peer.write(out) 不看返回值(net-relay.ts:163-168),慢连接会让 Node 的写缓冲无限增长。
    • 缓冲合并是 O(n²):每个 data 事件都把旧缓冲和新 chunk 复制成一个新数组(net-relay.ts:150-155)。攻击者把一个声明 1MB 的帧拆成小包慢慢发,复制总量就是平方级。
    • 握手不设防:upgrade 不校验 Origin、Sec-WebSocket-Version 和路径,也没有连接数上限(net-relay.ts:135-148)。socket.on('error') 静默吞掉错误(net-relay.ts:173)。
    • 身份与房间:中继不分配 peer,不分房间,所有连接在同一个广播域(net-relay.ts:163-168)。客户端的身份直接取报文第 2 字节(protocol.ts:152),可以伪造,见 #518。

复现

复现脚本 probe-relay-crash.mts(点击展开)

把脚本保存为仓库根目录下的 probe-relay-crash.mts,然后执行 npx tsx probe-relay-crash.mts。脚本只 import scripts/net-relay.ts,不修改任何文件。

// Probe: can a single crafted WebSocket frame header crash the relay process?
// Read-only w.r.t. the repo: only imports scripts/net-relay.ts.
import net from 'node:net'
import { startRelay } from './scripts/net-relay.ts'

const relay = await startRelay(0)
const port = Number(new URL(relay.url).port)
process.on('exit', code => { console.log(`[probe] process exit code=${String(code)}`) })

const socket = net.connect(port, '127.0.0.1', () => {
  socket.write(
    'GET / HTTP/1.1\r\nHost: x\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n'
    + 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\n\r\n',
  )
  setTimeout(() => {
    // FIN+binary, masked, 127 => 64-bit length = 2^40 (> 1_000_000 limit in decodeFrames)
    socket.write(Buffer.from([0x82, 0x80 | 127, 0, 0, 1, 0, 0, 0, 0, 0]))
    console.log('[probe] sent oversized frame header')
  }, 100)
})
socket.on('error', () => { /* ignore client-side errors */ })

setTimeout(() => {
  console.log(`[probe] relay still alive after 1s, clients=${String(relay.clients)}`)
  process.exit(0)
}, 1000)

实际输出如下;修复后应打印 relay still alive after 1s。

[probe] sent oversized frame header
[probe] process exit code=1
scripts/net-relay.ts:99
      if (big > 1_000_000n) throw new Error('frame too large')
Error: frame too large

根因

中继把"对端发来的数据不合法"当作编程错误抛出,而不是当作不可信输入处理。它是按"两个可信的浏览器互相转发"设计的(见 net-server.ts:1-9 的注释),但它实际上是一个监听 TCP 端口的服务。

修复指南

  1. 异常隔离(最小止血,先做):
    socket.on('data', (chunk: Buffer) => {
      try {
        handleChunk(socket, chunk)
      } catch (error) {
        log(`drop client: ${String(error)}`)
        socket.end(encodeFrame(OP_CLOSE, closePayload(1002)))
        socket.destroy()
        sockets.delete(socket)
      }
    })
    
    decodeFrames 改为返回结构化错误(例如 { kind: 'error', closeCode: 1009 }),不要 throw。
  2. 按 RFC 6455 校验帧:
    • 客户端帧没有 mask:用 1002 关闭。
    • 单帧上限与客户端的 maxMessageBytes 对齐(从 src/net/transport.ts 导出同一个常量),超限用 1009 关闭。
    • 控制帧的 payload 不超过 125 字节,且不得分片。
    • 分片帧:要么按 FIN/opcode 0 重组(重组后的总长同样受上限约束),要么直接关闭连接。不要静默丢弃。
    • 只转发 BINARY;收到 TEXT 用 1003 关闭。
  3. 缓冲与背压:
    • 每个连接的缓冲设上限(例如帧上限的 2 倍)。
    • 用 chunk 列表加偏移量代替每次整体复���。
    • peer.write() 返回 false 时记录待发字节数,超过阈值就断开慢连接。
  4. 连接管理:
    • 设连接数上限和握手超时。
    • Origin 走 allowlist(做成配置项),并校验 Sec-WebSocket-Version: 13。
    • error 事件至少在 verbose 模式下记录下来。
  5. 身份由中继分配:
    • 连接建立时分配 peer 序号。
    • 转发前覆写报文第 2 字节,或丢弃与连接身份不一致的包。
    • 引入房间(URL path 或首包)和人数上限。
    • 这一步和 #518 的客户端校验配合使用。

如果 M20(D2GS 服务端联机)最终取代 P2P 锁步,第 5 步只做最小版本即可。但 #508 的 WS↔TCP 桥同样是服务端组件,应该直接满足第 1–4 步的要求。

验收标准

  • 复现脚本打印 relay still alive,进程不退出;发起攻击的连接收到 1009 或 1002 后被关闭。

  • 新增基于真实 TCP 的中继健壮性测试,覆盖以下情况:

    • 超长帧头
    • 未 mask 的帧
    • TEXT 帧
    • 分片帧
    • 超过 4096B 的帧
    • 慢消费者(write 返回 false)
    • 发了半个帧头就断开

    每种情况下中继都存活,其他连接不受影响。

  • 一个恶意连接无法让其他客户端的 message 监听器抛异常。

  • 中继转发出去的报文,peer 字节等于中继分配的序号。

  • npm run typecheck 0 error;npx vitest run 全部通过。

相关

  • #518:客户端的语义校验。
  • #519:客户端 Transport 的细节问题。
  • #508:WS↔TCP 桥。
> 来源:#516|优先级 P0|评审编号 S1(含 S2、S6 的中继部分)|基线 `e475c2e` ## 问题描述 联机唯一的服务端组件是 `scripts/net-relay.ts`:191 行,手写 RFC 6455 帧编解码。任何能连上中继的客户端,只要发一个 10 字节的帧头,就能让整个 Node 进程以 exit 1 退出,所有玩家同时断线(**已复现**)。 除此之外,中继对帧格式、连接数、缓冲和背压都没有约束,也不分配身份、不分房间。目前它只监听 `127.0.0.1`;一旦为了远程联机放到反代或隧道后面,这些问题会直接暴露。 ## 证据 - 64 位帧长超过 1MB 时,`decodeFrames` 直接 `throw new Error('frame too large')`:[net-relay.ts:99](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/scripts/net-relay.ts#L99)。 - 它在 `socket.on('data')` 回调里被同步调用([net-relay.ts:150-155](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/scripts/net-relay.ts#L150-L155)),回调外没有 try/catch。入口 [net-server.ts:12-22](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/scripts/net-server.ts#L12-L22) 也没有任何进程级处理,异常未被捕获,进程就退出了。 - 同类问题: - **不校验 MASK 位**:RFC 6455 §5.1 要求客户端帧必须 mask,这里没有检查。 - **不处理 FIN/续帧**:分片消息只转发第一片,续帧(opcode 0)被静默丢弃([net-relay.ts:158-160](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/scripts/net-relay.ts#L158-L160))。 - **TEXT 帧被改成 BINARY 转发**([net-relay.ts:160-161](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/scripts/net-relay.ts#L160-L161))。客户端"只收二进制"的防线([transport.ts:362-364](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/net/transport.ts#L362-L364))因此失效。 - **单帧上限与客户端不一致**:中继允许 ≤1MB 的帧,客户端默认上限是 4096B([transport.ts:49](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/net/transport.ts#L49)),超限时直接在 `message` 监听器里 throw([transport.ts:383-384](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/net/transport.ts#L383-L384))。一个大帧会被原样广播,让所有客户端同时报错。 - **广播不做背压**:`peer.write(out)` 不看返回值([net-relay.ts:163-168](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/scripts/net-relay.ts#L163-L168)),慢连接会让 Node 的写缓冲无限增长。 - **缓冲合并是 O(n²)**:每个 data 事件都把旧缓冲和新 chunk 复制成一个新数组([net-relay.ts:150-155](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/scripts/net-relay.ts#L150-L155))。攻击者把一个声明 1MB 的帧拆成小包慢慢发,复制总量就是平方级。 - **握手不设防**:`upgrade` 不校验 `Origin`、`Sec-WebSocket-Version` 和路径,也没有连接数上限([net-relay.ts:135-148](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/scripts/net-relay.ts#L135-L148))。`socket.on('error')` 静默吞掉错误([net-relay.ts:173](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/scripts/net-relay.ts#L173))。 - **身份与房间**:中继不分配 peer,不分房间,所有连接在同一个广播域([net-relay.ts:163-168](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/scripts/net-relay.ts#L163-L168))。客户端的身份直接取报文第 2 字节([protocol.ts:152](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/net/protocol.ts#L152)),可以伪造,见 #518。 ## 复现 <details> <summary>复现脚本 probe-relay-crash.mts(点击展开)</summary> 把脚本保存为仓库根目录下的 `probe-relay-crash.mts`,然后执行 `npx tsx probe-relay-crash.mts`。脚本只 import `scripts/net-relay.ts`,不修改任何文件。 ```ts // Probe: can a single crafted WebSocket frame header crash the relay process? // Read-only w.r.t. the repo: only imports scripts/net-relay.ts. import net from 'node:net' import { startRelay } from './scripts/net-relay.ts' const relay = await startRelay(0) const port = Number(new URL(relay.url).port) process.on('exit', code => { console.log(`[probe] process exit code=${String(code)}`) }) const socket = net.connect(port, '127.0.0.1', () => { socket.write( 'GET / HTTP/1.1\r\nHost: x\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n' + 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\n\r\n', ) setTimeout(() => { // FIN+binary, masked, 127 => 64-bit length = 2^40 (> 1_000_000 limit in decodeFrames) socket.write(Buffer.from([0x82, 0x80 | 127, 0, 0, 1, 0, 0, 0, 0, 0])) console.log('[probe] sent oversized frame header') }, 100) }) socket.on('error', () => { /* ignore client-side errors */ }) setTimeout(() => { console.log(`[probe] relay still alive after 1s, clients=${String(relay.clients)}`) process.exit(0) }, 1000) ``` </details> 实际输出如下;修复后应打印 `relay still alive after 1s`。 ```text [probe] sent oversized frame header [probe] process exit code=1 scripts/net-relay.ts:99 if (big > 1_000_000n) throw new Error('frame too large') Error: frame too large ``` ## 根因 中继把"对端发来的数据不合法"当作编程错误抛出,而不是当作不可信输入处理。它是按"两个可信的浏览器互相转发"设计的(见 [net-server.ts:1-9](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/scripts/net-server.ts#L1-L9) 的注释),但它实际上是一个监听 TCP 端口的服务。 ## 修复指南 1. **异常隔离**(最小止血,先做): ```ts socket.on('data', (chunk: Buffer) => { try { handleChunk(socket, chunk) } catch (error) { log(`drop client: ${String(error)}`) socket.end(encodeFrame(OP_CLOSE, closePayload(1002))) socket.destroy() sockets.delete(socket) } }) ``` `decodeFrames` 改为返回结构化错误(例如 `{ kind: 'error', closeCode: 1009 }`),不要 throw。 2. **按 RFC 6455 校验帧**: - 客户端帧没有 mask:用 1002 关闭。 - 单帧上限与客户端的 `maxMessageBytes` 对齐(从 `src/net/transport.ts` 导出同一个常量),超限用 1009 关闭。 - 控制帧的 payload 不超过 125 字节,且不得分片。 - 分片帧:要么按 FIN/opcode 0 重组(重组后的总长同样受上限约束),要么直接关闭连接。不要静默丢弃。 - 只转发 BINARY;收到 TEXT 用 1003 关闭。 3. **缓冲与背压**: - 每个连接的缓冲设上限(例如帧上限的 2 倍)。 - 用 chunk 列表加偏移量代替每次整体复���。 - `peer.write()` 返回 false 时记录待发字节数,超过阈值就断开慢连接。 4. **连接管理**: - 设连接数上限和握手超时。 - `Origin` 走 allowlist(做成配置项),并校验 `Sec-WebSocket-Version: 13`。 - `error` 事件至少在 verbose 模式下记录下来。 5. **身份由中继分配**: - 连接建立时分配 peer 序号。 - 转发前覆写报文第 2 字节,或丢弃与连接身份不一致的包。 - 引入房间(URL path 或首包)和人数上限。 - 这一步和 #518 的客户端校验配合使用。 如果 M20(D2GS 服务端联机)最终取代 P2P 锁步,第 5 步只做最小版本即可。但 #508 的 WS↔TCP 桥同样是服务端组件,应该直接满足第 1–4 步的要求。 ## 验收标准 - [ ] 复现脚本打印 `relay still alive`,进程不退出;发起攻击的连接收到 1009 或 1002 后被关闭。 - [ ] 新增基于真实 TCP 的中继健壮性测试,覆盖以下情况: - 超长帧头 - 未 mask 的帧 - TEXT 帧 - 分片帧 - 超过 4096B 的帧 - 慢消费者(`write` 返回 false) - 发了半个帧头就断开 每种情况下中继都存活,其他连接不受影响。 - [ ] 一个恶意连接无法让其他客户端的 `message` 监听器抛异常。 - [ ] 中继转发出去的报文,peer 字节等于中继分配的序号。 - [ ] `npm run typecheck` 0 error;`npx vitest run` 全部通过。 ## 相关 - #518:客户端的语义校验。 - #519:客户端 Transport 的细节问题。 - #508:WS↔TCP 桥。
Author
Owner

已在提交 7bc21df(fix(net): harden WebSocket relay frame validation and crash isolation (#517))中完成修复,并在 5de27f8 中补充端到端与 136 关全量验证套件。

修复与验证摘要

  • 修复内容:补齐 RFC 6455 帧头边界校验(rsv === 0、客户端 masked === true、控制帧 <= 125B、二进制帧 opcode 校验、预分配前 maxPayloadBytes = 4096 限制)、连接/房间/速率上限与单连接错误隔离,并在 src/net/protocol.ts 中对截断/未知帧安全拒收而不使进程崩溃。
  • 专项回归测试:tests/p0-517-relay-hardening.test.ts
  • 总体验收门禁:npm run build 0 错误、14 个 P0/E2E 测试套件(190/190 用例)100% 通过、全仓 Vitest 6,460/6,460 通过、136/136 关无头浏览器巡检通过。
已在提交 [`7bc21df`](https://git.projectdiablo2.cn/troytt/diablo2-web/commit/7bc21df07c7fbd072af1a930ba6a53bcce1954e0)(`fix(net): harden WebSocket relay frame validation and crash isolation (#517)`)中完成修复,并在 [`5de27f8`](https://git.projectdiablo2.cn/troytt/diablo2-web/commit/5de27f8177d43524444de7aa301cc470c4f0844e) 中补充端到端与 136 关全量验证套件。 ### 修复与验证摘要 - **修复内容**:补齐 RFC 6455 帧头边界校验(`rsv === 0`、客户端 `masked === true`、控制帧 `<= 125B`、二进制帧 opcode 校验、预分配前 `maxPayloadBytes = 4096` 限制)、连接/房间/速率上限与单连接错误隔离,并在 `src/net/protocol.ts` 中对截断/未知帧安全拒收而不使进程崩溃。 - **专项回归测试**:[`tests/p0-517-relay-hardening.test.ts`](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/5de27f8177d43524444de7aa301cc470c4f0844e/tests/p0-517-relay-hardening.test.ts) - **总体验收门禁**:`npm run build` 0 错误、14 个 P0/E2E 测试套件(190/190 用例)100% 通过、全仓 Vitest 6,460/6,460 通过、136/136 关无头浏览器巡检通过。
Sign in to join this conversation.
No Label
No Milestone
No project
No Assignees
1 Participants
Notifications
Due Date
The due date is invalid or out of range. Please use the format 'yyyy-mm-dd'.

No due date set.

Dependencies

No dependencies set.

Reference: troytt/diablo2-web#517
No description provided.