[B-05][critical] 认证失败的连接永久留在 broker 连接表,无需凭据即可耗尽内存并拖慢所有按连接查找 #12

Closed
opened 2026-09-30 13:56:52 +08:00 by nixevol · 1 comment
Owner

编号:B-05 严重级:critical 工作线:broker(internal/broker) 来源:审查 I-03、P-03
依赖:无 被依赖:B-09 (#16)(同改 hooks.go 的 OnDisconnect,本条先合)

结论与统一方案

两份审查结论一致:OnConnect 在每个分支都调用 rememberPending 把连接放进 byClient,唯一删除点在 OnDisconnect;而 mochi 在 OnConnect 返回错误、OnConnectAuthenticate 返回 false、回 CONNACK 失败时都不调用 OnDisconnect。用不存在的编号反复连接,每次泄漏一个 *mqtt.Client(含 1024 槽发送通道与读缓冲),内存持续上涨直到 OOM;lookupConn、connStateOf 的线性扫描也随之变慢。

统一方案:

  1. OnConnect 只在认证通过时写入 byClient;拒绝与内部错误都不登记。OnConnectAuthenticate 查不到即返回 false。对客户端的行为不变:拒绝仍回 0x86,内部故障仍不回 CONNACK。
  2. connState 增加 established(在 OnSessionEstablished 置位)与创建时间;broker 每分钟清扫一次 !established && cl.Closed() 且存在超过 1 分钟的条目,覆盖"认证通过但 CONNACK 失败"等路径。已建立的连接一定会走 OnDisconnect,不在清扫范围。
  3. 增加 byConnID map[port.ConnID]*connState 索引,lookupConn、connStateOf 改为 O(1)。

严重级取 critical(两份审查分别判 critical 与 high):无需凭据、成本极低、结果是进程 OOM。

改动文件

internal/broker/hooks.go、internal/broker/broker.go,broker 测试。

与其他问题的交互 / 冲突说明

  • 与 B-01(同改 OnConnect)同属 broker 线,按 broker 线顺序合入。
  • B-09 会在 OnDisconnect 增加 closed 标记,需在本 issue 合入后 rebase。
  • 连接数指标只在会话建立和断开时更新,不受影响。

验收与测试

  • 用 net.Pipe 各循环 1000 次发"ClientID 与用户名不一致"、编号不存在、密码错误、认证内部错误四类 CONNECT,结束后 len(byClient)==0。
  • 认证成功后客户端立即断开的条目在清扫后消失。
  • 按连接代号查找的耗时不随失败连接数增长。

问题明细(各区审查原文,证据含文件与行号)

以下是本次复审各区审查报告的原文段落。A、M、I、P、S 开头的是原始发现编号(A 管理后台与网页、M 消息核心、I 身份认证群在线、P 传输平台部署、S SDK)。解决方案以本 issue 上方的"结论与统一方案"为准;原文里的方案与之不一致时,按上方执行。

[I-03] 认证失败的连接永久留在 broker 连接表,不用凭证就能耗尽内存

  • 严重级:critical
  • 分类:安全 / 资源泄漏
  • 现象与影响
    • OnConnect 在所有分支都执行 rememberPending,把连接放进 byClient,而唯一的删除点是 OnDisconnect。
    • mochi 只在会话建立、读循环结束后才调用 OnDisconnect。以下失败路径都直接返回,不调 OnDisconnect:
      • OnConnect 返回错误(内部故障);
      • OnConnectAuthenticate 返回 false:编号不存在、停用、密码错、令牌无效、锁定、ClientID 与用户名不符;
      • 发 CONNACK 失败。
    • 每次失败都泄漏一个 Client,约十几到几十 KiB:1024 槽的 outbound 通道、2 KiB 读缓冲,以及已关闭的 TLS/WS 连接对象。
    • 攻击者用不存在的编号就能刷,服务器连 argon2 都不算,成本极低;配错密码反复重连的裸设备、库故障期间的重连也会持续泄漏。
    • lookupConn(带连接代号时)和 connStateOf 对 byClient 做线性扫描,每次下行发布和上行处理都要遍历,泄漏越多 CPU 越高。
  • 证据
    • 各分支都记录:internal/broker/hooks.go:50-55、70-79、82-86、88-97。
    • 唯一删除点:hooks.go:196-205。
    • 线性扫描:broker/broker.go:294-306、412-421。
    • mochi 在 CONNACK 失败时直接返回:server.go:460-463;OnDisconnect 只在会话建立、读循环结束后调用:server.go:486。
	res, err := h.b.auth.Authenticate(context.Background(), endpointID, pk.Connect.Password, remoteIP)
	if err != nil {
		st.authErr = err
		h.rememberPending(cl, st)
		return err // mochi 不回 CONNACK,直接断开
	}
	st.authOK = res.OK
	st.sessionToken = res.SessionToken
	h.rememberPending(cl, st)
	return nil
	err = s.hooks.OnConnect(cl, pk)
	if err != nil {
		return err
	}

	cl.refreshDeadline(cl.State.Keepalive)
	if !s.hooks.OnConnectAuthenticate(cl, pk) { // [MQTT-3.1.4-2]
		err := s.SendConnack(cl, packets.ErrBadUsernameOrPassword, false, nil)
		if err != nil {
			return fmt.Errorf("invalid connection send ack: %w", err)
		}

		return packets.ErrBadUsernameOrPassword
	}
  • 文档依据:DEVELOPMENT 第 5 节钩子表(255);7.7 的内存连接表只记「当前连接」;PRD 第 8 节安全要求。
  • 为何不是故意设计:N1、N2、N3 各节都没有提到,属于遗漏清理。
  • 解决方案
    1. OnConnect 的错误分支不记录连接,直接返回错误。
    2. OnConnectAuthenticate 在 !authOK 时,先 delete(byClient, cl) 再返回 false。
    3. 加兜底:connState 增加 established 标记,在 OnSessionEstablished 时置位;在握手超时计时器或每分钟一次的清扫里,删除「未建立且 cl.Closed()」的条目,覆盖 CONNACK 失败这类路径。
    4. 增加按连接代号的索引 byConn map[ConnID]*connState,让查找变成 O(1)。
  • 改动文件:broker/hooks.go、broker/broker.go。
  • 与其他模块的交互/冲突风险:ACL 和上行检查只针对已建立的会话;连接数指标只在会话建立和断开时更新,都不受影响。
  • 需补测试:分别用不存在的编号、错误密码、库故障各连 100 次,断言 byClient 为空;成功连接后断开,也为空。
  • 置信度:代码阅读确定,已核对 mochi v2.7.9 源码。

[P-03] 认证失败的连接永久留在 byClient 表里:不需要凭据即可触发的内存泄漏,还会拖慢所有按 connID 的查找

  • 严重级:high
  • 分类:并发 / 安全
  • 现象与影响:
    • OnConnect 对每个 CONNECT 都会 rememberPending,唯一删除的地方在 OnDisconnect。
    • mochi 在 OnConnect 返回 error、或 OnConnectAuthenticate 返回 false 时直接返回,不会调 OnDisconnect。
    • 于是以下每一次连接都会把 *mqtt.Client 永久挂在表里,里面有已关闭的连接、2 KiB 读缓冲、1024 槽的发送通道、最长 64 KiB 的用户名:
      • ClientID 和用户名不一致(这条路径不查库、不算哈希);
      • 编号不存在、已停用;
      • 密码错误、令牌无效、处于锁定期;
      • 数据库故障。
    • 后果:
      • 攻击者不需要任何凭据,循环发 CONNECT 就能让内存一直涨到 OOM;设备拿旧令牌反复重连也会慢慢泄漏。
      • lookupConn 和 connStateOf 每次都要全表扫描,每条 PublishDown、每条上行、每个握手计时器都会随泄漏条目线性变慢。
      • 认证成功但回 CONNACK 失败(客户端等哈希超时已经断开)同样不走 OnDisconnect。
  • 证据:
func (s *Server) attachClient(cl *Client, listener string) error {
	// ...
	pk, err := s.readConnectionPacket(cl)
	// ...
	err = s.hooks.OnConnect(cl, pk)
	if err != nil {
		return err
	}

	cl.refreshDeadline(cl.State.Keepalive)
	if !s.hooks.OnConnectAuthenticate(cl, pk) { // [MQTT-3.1.4-2]
		// ...
		return packets.ErrBadUsernameOrPassword
	}

其余位置:hooks.go:50-55、70-79、82-86、196-199(唯一删除点);broker.go:294-306、412-421(全表扫描);mochi server.go:460-463(CONNACK 失败直接返回)。

  • 文档依据:PRD 第 8 节(1000 个同时在线、按 2000 留余量、登录防暴力);DEVELOPMENT 5。
  • 为何不是故意设计:没有记录,属于遗漏清理。
  • 解决方案:
    1. OnConnect 只在 res.OK 时写入 byClient;拒绝和内部错误都不登记。OnConnectAuthenticate 查不到就返回 false,对客户端的行为不变。
    2. connState 增加 established 标志(在 OnSessionEstablished 置位)和创建时间。broker 起一个每分钟一次的清扫,删除 !established && cl.Closed() 且存在超过 1 分钟的条目。已建立的连接一定会走 OnDisconnect,不能清。
    3. 增加 byConnID map[port.ConnID]*connState 索引,把 lookupConn 和 connStateOf 改成 O(1)。
  • 改动文件:internal/broker/hooks.go、broker.go
  • 与其他模块的交互/冲突风险:Session、Login 和指标都不受影响(失败连接本来就不计数)。
  • 需补测试:包内测试用 net.Pipe 循环 1000 次,分别发"ClientID 与用户名不一致"、RejectAuthenticator、errAuthenticator 三类 CONNECT,结束后 len(b.byClient)==0。另测"认证成功后客户端立刻断开"的条目在清扫后消失。
  • 置信度:代码阅读确定

复审基线:main 4059a15(2026-09-30)。编号说明、各工作线的合并顺序、共享文件归属见总览 #7。

**编号**:B-05 **严重级**:critical **工作线**:broker(internal/broker) **来源**:审查 I-03、P-03 **依赖**:无 **被依赖**:B-09 (#16)(同改 hooks.go 的 OnDisconnect,本条先合) ### 结论与统一方案 两份审查结论一致:`OnConnect` 在每个分支都调用 `rememberPending` 把连接放进 `byClient`,唯一删除点在 `OnDisconnect`;而 mochi 在 `OnConnect` 返回错误、`OnConnectAuthenticate` 返回 false、回 CONNACK 失败时都不调用 `OnDisconnect`。用不存在的编号反复连接,每次泄漏一个 `*mqtt.Client`(含 1024 槽发送通道与读缓冲),内存持续上涨直到 OOM;`lookupConn`、`connStateOf` 的线性扫描也随之变慢。 统一方案: 1. `OnConnect` 只在认证通过时写入 `byClient`;拒绝与内部错误都不登记。`OnConnectAuthenticate` 查不到即返回 false。对客户端的行为不变:拒绝仍回 0x86,内部故障仍不回 CONNACK。 2. `connState` 增加 `established`(在 `OnSessionEstablished` 置位)与创建时间;broker 每分钟清扫一次 `!established && cl.Closed()` 且存在超过 1 分钟的条目,覆盖"认证通过但 CONNACK 失败"等路径。已建立的连接一定会走 `OnDisconnect`,不在清扫范围。 3. 增加 `byConnID map[port.ConnID]*connState` 索引,`lookupConn`、`connStateOf` 改为 O(1)。 严重级取 critical(两份审查分别判 critical 与 high):无需凭据、成本极低、结果是进程 OOM。 ### 改动文件 `internal/broker/hooks.go`、`internal/broker/broker.go`,broker 测试。 ### 与其他问题的交互 / 冲突说明 - 与 B-01(同改 `OnConnect`)同属 broker 线,按 broker 线顺序合入。 - B-09 会在 `OnDisconnect` 增加 closed 标记,需在本 issue 合入后 rebase。 - 连接数指标只在会话建立和断开时更新,不受影响。 ### 验收与测试 - 用 net.Pipe 各循环 1000 次发"ClientID 与用户名不一致"、编号不存在、密码错误、认证内部错误四类 CONNECT,结束后 `len(byClient)==0`。 - 认证成功后客户端立即断开的条目在清扫后消失。 - 按连接代号查找的耗时不随失败连接数增长。 --- ### 问题明细(各区审查原文,证据含文件与行号) > 以下是本次复审各区审查报告的原文段落。A、M、I、P、S 开头的是原始发现编号(A 管理后台与网页、M 消息核心、I 身份认证群在线、P 传输平台部署、S SDK)。**解决方案以本 issue 上方的"结论与统一方案"为准**;原文里的方案与之不一致时,按上方执行。 #### [I-03] 认证失败的连接永久留在 broker 连接表,不用凭证就能耗尽内存 - **严重级**:critical - **分类**:安全 / 资源泄漏 - **现象与影响** - `OnConnect` 在所有分支都执行 `rememberPending`,把连接放进 `byClient`,而唯一的删除点是 `OnDisconnect`。 - mochi 只在会话建立、读循环结束后才调用 OnDisconnect。以下失败路径都直接返回,不调 OnDisconnect: - OnConnect 返回错误(内部故障); - OnConnectAuthenticate 返回 false:编号不存在、停用、密码错、令牌无效、锁定、ClientID 与用户名不符; - 发 CONNACK 失败。 - 每次失败都泄漏一个 Client,约十几到几十 KiB:1024 槽的 outbound 通道、2 KiB 读缓冲,以及已关闭的 TLS/WS 连接对象。 - 攻击者用不存在的编号就能刷,服务器连 argon2 都不算,成本极低;配错密码反复重连的裸设备、库故障期间的重连也会持续泄漏。 - `lookupConn`(带连接代号时)和 `connStateOf` 对 `byClient` 做线性扫描,每次下行发布和上行处理都要遍历,泄漏越多 CPU 越高。 - **证据** - 各分支都记录:`internal/broker/hooks.go:50-55`、`70-79`、`82-86`、`88-97`。 - 唯一删除点:`hooks.go:196-205`。 - 线性扫描:`broker/broker.go:294-306`、`412-421`。 - mochi 在 CONNACK 失败时直接返回:`server.go:460-463`;OnDisconnect 只在会话建立、读循环结束后调用:`server.go:486`。 ```70:79:e:\code\NixMsg\internal\broker\hooks.go res, err := h.b.auth.Authenticate(context.Background(), endpointID, pk.Connect.Password, remoteIP) if err != nil { st.authErr = err h.rememberPending(cl, st) return err // mochi 不回 CONNACK,直接断开 } st.authOK = res.OK st.sessionToken = res.SessionToken h.rememberPending(cl, st) return nil ``` ```437:450:E:\TempData\go\pkg\mod\github.com\mochi-mqtt\server\v2@v2.7.9\server.go err = s.hooks.OnConnect(cl, pk) if err != nil { return err } cl.refreshDeadline(cl.State.Keepalive) if !s.hooks.OnConnectAuthenticate(cl, pk) { // [MQTT-3.1.4-2] err := s.SendConnack(cl, packets.ErrBadUsernameOrPassword, false, nil) if err != nil { return fmt.Errorf("invalid connection send ack: %w", err) } return packets.ErrBadUsernameOrPassword } ``` - **文档依据**:DEVELOPMENT 第 5 节钩子表(255);7.7 的内存连接表只记「当前连接」;PRD 第 8 节安全要求。 - **为何不是故意设计**:N1、N2、N3 各节都没有提到,属于遗漏清理。 - **解决方案** 1. OnConnect 的错误分支不记录连接,直接返回错误。 2. OnConnectAuthenticate 在 `!authOK` 时,先 `delete(byClient, cl)` 再返回 false。 3. 加兜底:connState 增加 `established` 标记,在 OnSessionEstablished 时置位;在握手超时计时器或每分钟一次的清扫里,删除「未建立且 `cl.Closed()`」的条目,覆盖 CONNACK 失败这类路径。 4. 增加按连接代号的索引 `byConn map[ConnID]*connState`,让查找变成 O(1)。 - **改动文件**:`broker/hooks.go`、`broker/broker.go`。 - **与其他模块的交互/冲突风险**:ACL 和上行检查只针对已建立的会话;连接数指标只在会话建立和断开时更新,都不受影响。 - **需补测试**:分别用不存在的编号、错误密码、库故障各连 100 次,断言 `byClient` 为空;成功连接后断开,也为空。 - **置信度**:代码阅读确定,已核对 mochi v2.7.9 源码。 #### [P-03] 认证失败的连接永久留在 byClient 表里:不需要凭据即可触发的内存泄漏,还会拖慢所有按 connID 的查找 - **严重级**:high - **分类**:并发 / 安全 - **现象与影响**: - `OnConnect` 对每个 CONNECT 都会 `rememberPending`,唯一删除的地方在 `OnDisconnect`。 - mochi 在 `OnConnect` 返回 error、或 `OnConnectAuthenticate` 返回 false 时直接返回,不会调 `OnDisconnect`。 - 于是以下每一次连接都会把 `*mqtt.Client` 永久挂在表里,里面有已关闭的连接、2 KiB 读缓冲、1024 槽的发送通道、最长 64 KiB 的用户名: - ClientID 和用户名不一致(这条路径不查库、不算哈希); - 编号不存在、已停用; - 密码错误、令牌无效、处于锁定期; - 数据库故障。 - 后果: - 攻击者不需要任何凭据,循环发 CONNECT 就能让内存一直涨到 OOM;设备拿旧令牌反复重连也会慢慢泄漏。 - `lookupConn` 和 `connStateOf` 每次都要全表扫描,每条 PublishDown、每条上行、每个握手计时器都会随泄漏条目线性变慢。 - 认证成功但回 CONNACK 失败(客户端等哈希超时已经断开)同样不走 OnDisconnect。 - **证据**: ```406:450:E:\TempData\go\pkg\mod\github.com\mochi-mqtt\server\v2@v2.7.9\server.go func (s *Server) attachClient(cl *Client, listener string) error { // ... pk, err := s.readConnectionPacket(cl) // ... err = s.hooks.OnConnect(cl, pk) if err != nil { return err } cl.refreshDeadline(cl.State.Keepalive) if !s.hooks.OnConnectAuthenticate(cl, pk) { // [MQTT-3.1.4-2] // ... return packets.ErrBadUsernameOrPassword } ``` 其余位置:`hooks.go:50-55`、`70-79`、`82-86`、`196-199`(唯一删除点);`broker.go:294-306`、`412-421`(全表扫描);mochi `server.go:460-463`(CONNACK 失败直接返回)。 - **文档依据**:PRD 第 8 节(1000 个同时在线、按 2000 留余量、登录防暴力);DEVELOPMENT 5。 - **为何不是故意设计**:没有记录,属于遗漏清理。 - **解决方案**: 1. `OnConnect` 只在 `res.OK` 时写入 `byClient`;拒绝和内部错误都不登记。`OnConnectAuthenticate` 查不到就返回 false,对客户端的行为不变。 2. `connState` 增加 `established` 标志(在 `OnSessionEstablished` 置位)和创建时间。broker 起一个每分钟一次的清扫,删除 `!established && cl.Closed()` 且存在超过 1 分钟的条目。已建立的连接一定会走 OnDisconnect,不能清。 3. 增加 `byConnID map[port.ConnID]*connState` 索引,把 `lookupConn` 和 `connStateOf` 改成 O(1)。 - **改动文件**:`internal/broker/hooks.go`、`broker.go` - **与其他模块的交互/冲突风险**:Session、Login 和指标都不受影响(失败连接本来就不计数)。 - **需补测试**:包内测试用 net.Pipe 循环 1000 次,分别发"ClientID 与用户名不一致"、RejectAuthenticator、errAuthenticator 三类 CONNECT,结束后 `len(b.byClient)==0`。另测"认证成功后客户端立刻断开"的条目在清扫后消失。 - **置信度**:代码阅读确定 --- <sub>复审基线:main `4059a15`(2026-09-30)。编号说明、各工作线的合并顺序、共享文件归属见总览 #7。</sub>
nixevol added the P0-criticallane/brokerreview-2026-09-30 labels 2026-09-30 13:56:52 +08:00
Author
Owner

已合入 origin/main 0c9b459。落地提交 0b9ce03 fix: 认证失败不泄漏连接表并脱敏 mochi 整包日志 (#12)。

已合入 origin/main `0c9b459`。落地提交 `0b9ce03` fix: 认证失败不泄漏连接表并脱敏 mochi 整包日志 (#12)。
Sign in to join this conversation.