[P0][CR-S2/S3] 锁步联机:peer 身份自报可伪造输入;越界 peer 抛异常逃逸;未来 tick 无界缓存 #518

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

来源:#516|优先级 P0|评审编号 S2、S3|基线 e475c2e

问题描述

NetplaySession.receive 只做了语法校验(decodeMessage 的长度检查),没有做语义校验。结果有四个问题:

  1. 输入可伪造:任何连接都能冒充任意 peer 提交输入,甚至能覆盖本机玩家自己已经排队的输入(已复现)。
  2. 越界 peer 让异常逃逸:越界的 peer 序号会让 submit 抛异常,异常逃出 transport 回调,malformed 计数仍为 0(已复现)。
  3. 未来 tick 无上限:tick = 4e9 也会被接受并常驻内存,远端 hash 也一样(已复现)。
  4. 移动分量没有钳制:可以 32 倍速移动,步子够大还能穿墙(代码阅读得出,未单独复现)。

证据

  • 身份自报:发送者身份直接取报文第 2 字节,const peer = data[1](protocol.ts:152)。decodeMessage 的注释说 "Every field is range-checked"(protocol.ts:143-144),实际只校验了 hello 里的 peers 数量(protocol.ts:157)。
  • 只有 hello 分支做了检查:
  • 异常逃逸:receive 只捕获解码阶段的 ProtocolError(netplay.ts:460-468)。它自己的注释却写着 "one bad packet from a confused peer should not take the world down, and the counter makes it visible"(netplay.ts:451-456),越界 peer 这种情况两条都没做到。
  • hash 无窗口:远端 hash 按 tick 存进 pendingRemoteHashes,没有窗口限制(netplay.ts:502-504)。drainRemoteHashes 只淘汰过旧的,未来的 hash 会一直留着(netplay.ts:377-392)。
  • 移动:

复现

复现脚本 probe-netplay-spoof.mts(点击展开)

把脚本保存为仓库根目录下的 probe-netplay-spoof.mts,然后执行 npx tsx probe-netplay-spoof.mts。

脚本用 memoryHub(3) 建了三个端点:peer0、peer1,以及一个不属于该局的端点 2。

// Probe: can a third party (or buggy peer) overwrite peer 0's OWN input, and does an
// out-of-range peer index throw out of the transport message handler?
// Read-only w.r.t. the repo: only imports src/net/*.
import { NetplaySession } from './src/net/netplay.ts'
import { encodeMessage } from './src/net/protocol.ts'
import { memoryHub } from './src/net/transport.ts'
import type { InputFrame, LockstepSimulation } from './src/net/lockstep.ts'

const { hub, ends } = memoryHub(3)
const seenByPeer0: { tick: number; p0x: number }[] = []
let tick0 = 0
const sim0: LockstepSimulation = {
  advance: (inputs: readonly InputFrame[]) => { seenByPeer0.push({ tick: tick0++, p0x: inputs[0]!.movement.x }) },
  hash: () => 0,
}
const sim1: LockstepSimulation = { advance: () => {}, hash: () => 0 }
const s0 = new NetplaySession({ peer: 0, peers: 2, seed: 1, inputDelayTicks: 2 }, sim0, ends[0]!)
const s1 = new NetplaySession({ peer: 1, peers: 2, seed: 1, inputDelayTicks: 2 }, sim1, ends[1]!)
s0.setIntent({ movement: { x: 0.5, y: 0 } })
s0.start(); s1.start()
for (let i = 0; i < 6; i += 1) { s0.pump(); s1.pump(); hub.flush() }

// "attacker" = hub end 2, a raw transport that is not even part of the session
const from = s0.lockstep.tick
for (let t = from; t < from + 4; t += 1) {
  ends[2]!.send(encodeMessage({ kind: 'input', peer: 0, frame: { tick: t, movement: { x: -1, y: 0 }, attack: true, pickup: false, talk: false, skill: 0 } }))
}
hub.flush()
for (let i = 0; i < 6; i += 1) { s0.pump(); s1.pump(); hub.flush() }
console.log('[probe] local intent x=0.5; peer0 own input as simulated by peer0:')
console.log(seenByPeer0.map(e => `${String(e.tick)}:${String(e.p0x)}`).join(' '))

// Far-future tick: accepted and retained (unbounded inbox)
const accepted = s0.lockstep.submit(1, { tick: 4_000_000_000, movement: { x: 0, y: 0 }, attack: false, pickup: false, talk: false, skill: 0 })
console.log(`[probe] far-future tick 4e9 accepted=${String(accepted)}`)

// Out-of-range peer index
try {
  ends[2]!.send(encodeMessage({ kind: 'input', peer: 7, frame: { tick: 999, movement: { x: 0, y: 0 }, attack: false, pickup: false, talk: false, skill: 0 } }))
  hub.flush()
  console.log('[probe] peer=7 input handled without throwing')
} catch (error) {
  console.log(`[probe] peer=7 input THREW out of transport handler: ${(error as Error).message}`)
}
console.log(`[probe] malformed counter s0=${String(s0.stats.malformed)}`)

输出:

[probe] local intent x=0.5; peer0 own input as simulated by peer0:
0:0.5 1:0.5 2:0.5 3:0.5 4:-1 5:-1 6:0.5 7:0.5 8:0.5 9:0.5
[probe] far-future tick 4e9 accepted=true
[probe] peer=7 input THREW out of transport handler: unknown peer 7
[probe] malformed counter s0=0

解读:

  • 第 4、5 tick:peer0 自己的意图是 x=0.5,但它模拟出来的自己却是 x=-1。这是端点 2 冒充 peer0,覆盖了 peer0 已经排队的本地输入。
  • 第 6、7 tick 没有被改,是因为 peer0 随后自己提交的输入又把它覆盖了回来,谁后写谁赢。
  • 其余三行:tick=4e9 被接受;peer=7 的异常逃出了 hub.flush();malformed 仍为 0。

根因

两层之间没有人负责语义校验:

  • 协议层的���释假设"解码就等于校验";
  • 会话层假设"解码过的消息都可信"。

中继也不分配身份,见 #517。

修复指南

  1. 在 receive 里集中做语义校验。不合法的消息一律 malformed += 1 后丢弃,绝不 throw:

    private isValid(message: NetMessage): boolean {
      const { peer: self, peers, inputDelayTicks } = this.options
      if (message.peer === self || message.peer >= peers) return false // input / hash / bye 都要查
      const now = this.lockstep.tick
      if (message.kind === 'input') {
        const { tick, movement } = message.frame
        if (tick < now || tick > now + inputDelayTicks + INPUT_SLACK_TICKS) return false
        if (!isUnitMovement(movement)) return false
      }
      if (message.kind === 'hash') {
        if (message.tick + HASH_HISTORY_TICKS < now || message.tick > now + HASH_SLACK_TICKS) return false
      }
      return true
    }
    

    isUnitMovement 在定点整数上判断:|x| ≤ 1000 && |y| ≤ 1000 && x*x + y*y ≤ 1000*1000。用整数运算,跨浏览器结果一致。skill 字段目前被联机模拟忽略;接入之前先定义它的合法范围。

  2. 同一 peer、同一 tick 的输入只接受第一次。内容不同的重复输入记为 malformed。本地 peer 的输入永远不能被远端覆盖。

  3. LockstepSession.submit 不再 throw:

    • 未知 peer 或超出窗口的 tick 返回 false(或结构化错误)。
    • 每个 peer 的输入 Map 设大小上限。
  4. 模拟侧再做一次防护:

    • 移动向量钳制到单位长度。
    • moveWithCollision 按子步推进,每步不超过一个碰撞子格。

    即使以后有了服务器权威,模拟也不应信任输入的幅度。

  5. 服务器分配身份之后(#517 第 5 步),客户端还要校验报文的 peer 与中继标注的来源一致。

    注意,纯 P2P 锁步防不住合法成员作弊,它可以提交任何"范围合法"的输入。要反作弊,需要服务器权威(M20),或者至少做消息签名。

  6. 修正 decodeMessage 的注释,让它与实际行为一致;或者把范围校验真正放进解码器,前提是解码器能拿到 peers。

验收标准

  • 复现脚本的三项结果都反过来:
    • peer0 模拟出的自身输入全部是 0.5;
    • tick=4e9 被拒绝;
    • peer=7 不抛异常,且 malformed ≥ 1。
  • 新增测试覆盖下列情况,每个用例都断言 malformed 递增、模拟结果不变:
    • 冒充本机 peer
    • 越界 peer
    • 同一 tick 的重复输入
    • 过去的 tick 和过远的未来 tick
    • |movement| > 1
    • 未来 hash 洪泛
  • 洪泛 10⁵ 条消息后,输入缓存和 pendingRemoteHashes 的大小仍然有界。
  • npm run typecheck 0 error;npx vitest run 全部通过。

相关

  • #517:中继侧由服务器分配身份。
  • #519:desync、超时与状态哈希。
  • #523:锁步的前提是模拟本身确定。
> 来源:#516|优先级 P0|评审编号 S2、S3|基线 `e475c2e` ## 问题描述 `NetplaySession.receive` 只做了**语法**校验(`decodeMessage` 的长度检查),没有做**语义**校验。结果有四个问题: 1. **输入可伪造**:任何连接都能冒充任意 peer 提交输入,甚至能覆盖本机玩家自己已经排队的输入(**已复现**)。 2. **越界 peer 让异常逃逸**:越界的 peer 序号会让 `submit` 抛异常,异常逃出 transport 回调,`malformed` 计数仍为 0(**已复现**)。 3. **未来 tick 无上限**:`tick = 4e9` 也会被接受并常驻内存,远端 hash 也一样(**已复现**)。 4. **移动分量没有钳制**:可以 32 倍速移动,步子够大还能穿墙(代码阅读得出,未单独复现)。 ## 证据 - **身份自报**:发送者身份直接取报文第 2 字节,`const peer = data[1]`([protocol.ts:152](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/net/protocol.ts#L152))。`decodeMessage` 的注释说 "Every field is range-checked"([protocol.ts:143-144](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/net/protocol.ts#L143-L144)),实际只校验了 hello 里的 peers 数量([protocol.ts:157](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/net/protocol.ts#L157))。 - **只有 hello 分支做了检查**: - `hello` 分支会拒绝 `peer === self` 和越界 peer([netplay.ts:473-479](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/net/netplay.ts#L473-L479))。 - `input` 分支直接 `submit(message.peer, …)`([netplay.ts:499-501](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/net/netplay.ts#L499-L501))。 - `submit` 用 `Map.set` 覆盖已有输入,对未来 tick 也没有限制([lockstep.ts:155-160](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/net/lockstep.ts#L155-L160))。 - **异常逃逸**:`receive` 只捕获解码阶段的 `ProtocolError`([netplay.ts:460-468](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/net/netplay.ts#L460-L468))。它自己的注释却写着 "one bad packet from a confused peer should not take the world down, and the counter makes it visible"([netplay.ts:451-456](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/net/netplay.ts#L451-L456)),越界 peer 这种情况两条都没做到。 - **hash 无窗口**:远端 hash 按 tick 存进 `pendingRemoteHashes`,没有窗口限制([netplay.ts:502-504](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/net/netplay.ts#L502-L504))。`drainRemoteHashes` 只淘汰过旧的,未来的 hash 会一直留着([netplay.ts:377-392](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/net/netplay.ts#L377-L392))。 - **移动**: - 解码后的范围是 ±32.767(int16 / 1000,[protocol.ts:168](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/net/protocol.ts#L168))。 - `tickPlayer` 直接用 `movement × speed`([combat.ts:936-940](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/game/combat.ts#L936-L940))。 - `moveWithCollision` 只检查终点,中间不分步([combat.ts:762-774](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/game/combat.ts#L762-L774))。 ## 复现 <details> <summary>复现脚本 probe-netplay-spoof.mts(点击展开)</summary> 把脚本保存为仓库根目录下的 `probe-netplay-spoof.mts`,然后执行 `npx tsx probe-netplay-spoof.mts`。 脚本用 `memoryHub(3)` 建了三个端点:peer0、peer1,以及一个**不属于该局**的端点 2。 ```ts // Probe: can a third party (or buggy peer) overwrite peer 0's OWN input, and does an // out-of-range peer index throw out of the transport message handler? // Read-only w.r.t. the repo: only imports src/net/*. import { NetplaySession } from './src/net/netplay.ts' import { encodeMessage } from './src/net/protocol.ts' import { memoryHub } from './src/net/transport.ts' import type { InputFrame, LockstepSimulation } from './src/net/lockstep.ts' const { hub, ends } = memoryHub(3) const seenByPeer0: { tick: number; p0x: number }[] = [] let tick0 = 0 const sim0: LockstepSimulation = { advance: (inputs: readonly InputFrame[]) => { seenByPeer0.push({ tick: tick0++, p0x: inputs[0]!.movement.x }) }, hash: () => 0, } const sim1: LockstepSimulation = { advance: () => {}, hash: () => 0 } const s0 = new NetplaySession({ peer: 0, peers: 2, seed: 1, inputDelayTicks: 2 }, sim0, ends[0]!) const s1 = new NetplaySession({ peer: 1, peers: 2, seed: 1, inputDelayTicks: 2 }, sim1, ends[1]!) s0.setIntent({ movement: { x: 0.5, y: 0 } }) s0.start(); s1.start() for (let i = 0; i < 6; i += 1) { s0.pump(); s1.pump(); hub.flush() } // "attacker" = hub end 2, a raw transport that is not even part of the session const from = s0.lockstep.tick for (let t = from; t < from + 4; t += 1) { ends[2]!.send(encodeMessage({ kind: 'input', peer: 0, frame: { tick: t, movement: { x: -1, y: 0 }, attack: true, pickup: false, talk: false, skill: 0 } })) } hub.flush() for (let i = 0; i < 6; i += 1) { s0.pump(); s1.pump(); hub.flush() } console.log('[probe] local intent x=0.5; peer0 own input as simulated by peer0:') console.log(seenByPeer0.map(e => `${String(e.tick)}:${String(e.p0x)}`).join(' ')) // Far-future tick: accepted and retained (unbounded inbox) const accepted = s0.lockstep.submit(1, { tick: 4_000_000_000, movement: { x: 0, y: 0 }, attack: false, pickup: false, talk: false, skill: 0 }) console.log(`[probe] far-future tick 4e9 accepted=${String(accepted)}`) // Out-of-range peer index try { ends[2]!.send(encodeMessage({ kind: 'input', peer: 7, frame: { tick: 999, movement: { x: 0, y: 0 }, attack: false, pickup: false, talk: false, skill: 0 } })) hub.flush() console.log('[probe] peer=7 input handled without throwing') } catch (error) { console.log(`[probe] peer=7 input THREW out of transport handler: ${(error as Error).message}`) } console.log(`[probe] malformed counter s0=${String(s0.stats.malformed)}`) ``` </details> 输出: ```text [probe] local intent x=0.5; peer0 own input as simulated by peer0: 0:0.5 1:0.5 2:0.5 3:0.5 4:-1 5:-1 6:0.5 7:0.5 8:0.5 9:0.5 [probe] far-future tick 4e9 accepted=true [probe] peer=7 input THREW out of transport handler: unknown peer 7 [probe] malformed counter s0=0 ``` 解读: - **第 4、5 tick**:peer0 自己的意图是 `x=0.5`,但它模拟出来的自己却是 `x=-1`。这是端点 2 冒充 peer0,覆盖了 peer0 已经排队的本地输入。 - **第 6、7 tick** 没有被改,是因为 peer0 随后自己提交的输入又把它覆盖了回来,谁后写谁赢。 - **其余三行**:`tick=4e9` 被接受;`peer=7` 的异常逃出了 `hub.flush()`;`malformed` 仍为 0。 ## 根因 两层之间没有人负责语义校验: - 协议层的���释假设"解码就等于校验"; - 会话层假设"解码过的消息都可信"。 中继也不分配身份,见 #517。 ## 修复指南 1. **在 `receive` 里集中做语义校验**。不合法的消息一律 `malformed += 1` 后丢弃,**绝不 throw**: ```ts private isValid(message: NetMessage): boolean { const { peer: self, peers, inputDelayTicks } = this.options if (message.peer === self || message.peer >= peers) return false // input / hash / bye 都要查 const now = this.lockstep.tick if (message.kind === 'input') { const { tick, movement } = message.frame if (tick < now || tick > now + inputDelayTicks + INPUT_SLACK_TICKS) return false if (!isUnitMovement(movement)) return false } if (message.kind === 'hash') { if (message.tick + HASH_HISTORY_TICKS < now || message.tick > now + HASH_SLACK_TICKS) return false } return true } ``` `isUnitMovement` 在定点整数上判断:`|x| ≤ 1000 && |y| ≤ 1000 && x*x + y*y ≤ 1000*1000`。用整数运算,跨浏览器结果一致。`skill` 字段目前被联机模拟忽略;接入之前先定义它的合法范围。 2. **同一 peer、同一 tick 的输入只接受第一次**。内容不同的重复输入记为 malformed。本地 peer 的输入永远不能被远端覆盖。 3. **`LockstepSession.submit` 不再 throw**: - 未知 peer 或超出窗口的 tick 返回 `false`(或结构化错误)。 - 每个 peer 的输入 Map 设大小上限。 4. **模拟侧再做一次防护**: - 移动向量钳制到单位长度。 - `moveWithCollision` 按子步推进,每步不超过一个碰撞子格。 即使以后有了服务器权威,模拟也不应信任输入的幅度。 5. **服务器分配身份之后**(#517 第 5 步),客户端还要校验报文的 peer 与中继标注的来源一致。 注意,纯 P2P 锁步防不住**合法成员**作弊,它可以提交任何"范围合法"的输入。要反作弊,需要服务器权威(M20),或者至少做消息签名。 6. **修正 `decodeMessage` 的注释**,让它与实际行为一致;或者把范围校验真正放进解码器,前提是解码器能拿到 `peers`。 ## 验收标准 - [ ] 复现脚本的三项结果都反过来: - peer0 模拟出的自身输入全部是 `0.5`; - `tick=4e9` 被拒绝; - `peer=7` 不抛异常,且 `malformed ≥ 1`。 - [ ] 新增测试覆盖下列情况,每个用例都断言 `malformed` 递增、模拟结果不变: - 冒充本机 peer - 越界 peer - 同一 tick 的重复输入 - 过去的 tick 和过远的未来 tick - `|movement| > 1` - 未来 hash 洪泛 - [ ] 洪泛 10⁵ 条消息后,输入缓存和 `pendingRemoteHashes` 的大小仍然有界。 - [ ] `npm run typecheck` 0 error;`npx vitest run` 全部通过。 ## 相关 - #517:中继侧由服务器分配身份。 - #519:desync、超时与状态哈希。 - #523:锁步的前提是模拟本身确定。
Author
Owner

已在提交 995f594(fix(net): enforce lockstep peerId authentication, bounds checks, and future-tick window (#518))中完成修复,并在 5de27f8 中补充端到端与 136 关全量验证套件。

修复与验证摘要

  • 修复内容:在中继会话层绑定并覆写服务端分配的真实 peerId(杜绝客户端伪造他人 peerId),在 LockstepSession / NetplaySession 严格校验 0 <= peer < peers、peer !== self、未来窗口 [currentTick, currentTick + MAX_FUTURE_TICKS](MAX_FUTURE_TICKS = 64)、单位位移边界及多 peer (peer, tick) 首写生效。
  • 专项回归测试:tests/p0-518-lockstep-security.test.ts
  • 总体验收门禁:npm run build 0 错误、14 个 P0/E2E 测试套件(190/190 用例)100% 通过、全仓 Vitest 6,460/6,460 通过、136/136 关无头浏览器巡检通过。
已在提交 [`995f594`](https://git.projectdiablo2.cn/troytt/diablo2-web/commit/995f594fe7ec8eb90df818c3977d8ad3c490ddc2)(`fix(net): enforce lockstep peerId authentication, bounds checks, and future-tick window (#518)`)中完成修复,并在 [`5de27f8`](https://git.projectdiablo2.cn/troytt/diablo2-web/commit/5de27f8177d43524444de7aa301cc470c4f0844e) 中补充端到端与 136 关全量验证套件。 ### 修复与验证摘要 - **修复内容**:在中继会话层绑定并覆写服务端分配的真实 `peerId`(杜绝客户端伪造他人 `peerId`),在 `LockstepSession` / `NetplaySession` 严格校验 `0 <= peer < peers`、`peer !== self`、未来窗口 `[currentTick, currentTick + MAX_FUTURE_TICKS]`(`MAX_FUTURE_TICKS = 64`)、单位位移边界及多 peer `(peer, tick)` 首写生效。 - **专项回归测试**:[`tests/p0-518-lockstep-security.test.ts`](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/5de27f8177d43524444de7aa301cc470c4f0844e/tests/p0-518-lockstep-security.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#518
No description provided.