乐于分享
好东西不私藏

腾讯云在 OpenClaw 上游安全治理中的代码评审实践

腾讯云在 OpenClaw 上游安全治理中的代码评审实践

作者 | 龚奕瑞

导读:开源项目的安全治理,往往不只发生在漏洞披露之后,也发生在一次次上游代码评审、缺陷复现和回归测试中。

本文以腾讯云团队参与 OpenClaw 上游治理的四个已合入 PR 为例,拆解 HMAC 签名校验时序侧信道、云厂商凭证日志脱敏缺口,以及网关运行时状态泄漏的治理过程,并进一步总结可复用的代码评审检查项。

文章内容基于作者个人技术实践与独立分析,旨在分享工程经验,仅代表个人观点。

范围与索引

本文围绕四个已合入主干的安全修复展开:涉及签名校验时序侧信道防护、日志脱敏规则补全,以及网关节点状态生命周期对齐。

下表先给出每个PR的简要内容介绍:

PR
所在模块
缺陷类型
根因分析
严重程度
#55663 LINE 通道extensions/line/src/signature.ts HMAC 签名校验时序侧信道 长度不一致即早返,跳过常量时间比较 中高
#58097 Nextcloud Talk 通道extensions/nextcloud-talk/src/signature.ts HMAC 签名校验时序侧信道 手写 charCodeAt 循环被 V8 JIT 优化抹平 中高
#58162 src/logging/redact.ts 凭证日志脱敏缺口 默认正则未覆盖 AKID/LTAI/hf_/r8_ 前缀
#58179 src/gateway/node-pending-work.ts 对象生命周期未对齐 外层 Map 与内层状态生命周期错位

四项改动的指标卡片与三类工程边界分组

LINE 通道的时序侧信道(#55663

严重程度:中高影响范围:所有启用 LINE 通道的 OpenClaw 部署

1.现场

LINE 平台会在每次 Webhook 事件中携带 x-line-signature 头部,其值是用渠道密钥对消息体做 HMAC-SHA256、再 Base64 编码得到的摘要,长度通常为44个字符。

接入方收到事件后必须自行计算签名并与请求头比对。这一步必须以常量时间完成;一旦耗时与输入存在任何关联关系,攻击者就可以利用耗时差作为时序预言(timing oracle),通过反复发起请求,探测出正确的签名长度。

2.根因

修复前的实现:

const hashBuffer = Buffer.from(hash);const signatureBuffer = Buffer.from(signature);// Use constant-time comparison to prevent timing attacks.if (hashBuffer.length !== signatureBuffer.length) {  return false;}return crypto.timingSafeEqual(hashBuffer, signatureBuffer);

虽然注释里写明了 constant-time comparison(指常量时间比较),但开头先判断本地计算出的摘要与请求头签名的长度是否一致,长度不一致就直接返回。这里已经把常量时间属性破坏掉了。

  • 长度不等的分支直接 return false,根本不会执行 timingSafeEqual;长度相等的分支要走完一次完整比较。两条路径在外部观察者眼里耗时差异明显,整体响应耗时与长度是否匹配强相关。

  • 虽然泄漏的不是签名内容本身,是合法签名长度。但是该分支造成可观察的格式长度差异,属于密码学比较路径上的防御纵深缺陷

修复前后的控制流对比:左侧只要长度不匹配,就会直接走旁路;右侧将两个待比较的数据填充到相同长度,然后无条件执行完整的常量时间比较,从而消除长度不等时跳过受信比较原语的明显快路径。

3.影响范围与风险定级

  • 严重程度:中高。本身不直接构成签名伪造,但属于 “HMAC 校验失去常量时间属性” 这一标准密码学反模式,在受控网络条件下可作为远程预言使用。

  • 影响范围:所有启用 LINE 通道的 OpenClaw 部署,没有前置版本约束。

  • 命中条件:攻击者只需具备向部署方 Webhook 端点发起请求并测量 RTT 的能力,无需持有任何账号或密钥。

4.复现:差分计时实验

内网环境,固定 endpoint 与上层网络条件,向同一 Webhook 路径提交两组伪造签名,每组 N≥10^4 次:

  • 组 A:长度恰为 44(HMAC-SHA256 经 Base64 编码后的典型长度);

  • 组 B:长度刻意偏离 44(如 32、96)。

对两组 RTT 做差分检验:

Δt¯=tB¯−tA¯

修复前 Δt¯ 显著为负。组 B 因长度早返跳过 timingSafeEqual 而响应更快,差值远超网络抖动 1σ 区间,可作为长度判定的远程预言;修复后两组分布完全重合,分布形态见下图。

差分计时实验:修复前组 A / B 的耗时均值相差约 84 微秒,远超网络抖动;修复后两组分布完全重合。

5.修复策略与回归保证

修复的关键是让 timingSafeEqual 从条件触发变成无条件触发:两侧 buffer 先 padding 到统一长度,无条件调一次受信原语,最后才把长度判断和比较结果通过 && 合并:

// Pad to equal length before constant-time comparison to prevent// leaking length information via early-return timing.const maxLen = Math.max(hashBuffer.length, signatureBuffer.length);const paddedHash = Buffer.alloc(maxLen);const paddedSig = Buffer.alloc(maxLen);hashBuffer.copy(paddedHash);signatureBuffer.copy(paddedSig);// Call timingSafeEqual unconditionally to ensure constant-time execution// regardless of length mismatch (avoids && short-circuit timing leak).const timingResult = crypto.timingSafeEqual(paddedHash, paddedSig);return hashBuffer.length === signatureBuffer.length && timingResult;

改动新增 11 行。配套测试覆盖三种场景:长度等且内容等、长度等内容不等、长度不等,并显式断言 timingSafeEqual 在所有分支都被实际调用,避免后续维护者出于风格考量重新引入长度快路径。

Nextcloud Talk 的更隐蔽侧信道(#58097

严重程度:中高影响范围:所有启用 Nextcloud Talk 通道的 OpenClaw 部署

1.现场

Nextcloud Talk 通道也走 HMAC-SHA256 校验 Webhook 签名。作者手写了一段按位 XOR 比对:

if (signature.length !== expected.length) {  return false;}let result = 0;for (let i = 0; i < signature.length; i++) {  result |= signature.charCodeAt(i) ^ expected.charCodeAt(i);}return result === 0;

这段代码先早返长度差异,再对每个字节做 XOR 累计,最后看 result 是否为零。循环主体本身是常见的常量时间写法,但在 V8 + TypeScript 的运行环境里还不够。

2.根因

“常量时间”是可执行代码的属性,不是源码的属性,问题分两层。

(1)第一层:长度旁路

循环之前的 if (signature.length !== expected.length) return false; 与 #55663 同型,常量时间属性在进入循环前已经丢失。

即便把这一早返改写成 diff |= signature.length ^ expected.length 再走完循环,循环主体仍只跑较短一侧的字节数,长度差异越大耗时越短,长度旁路依然存在。

(2)第二层:JIT 抹平

比长度短路更隐蔽是运行时行为。

Node.js 的 V8 引擎会把热点函数交给 TurboFan 重新编译为本地代码;优化器允许在不改变语义的前提下消除冗余计算。

从外部去观察,循环中的语句唯一的语义是“收集差异比特、最终判 0”。“一旦发现差异即可终止”与“跑完所有字节”在 V8 看来语义等价。

退化链如下:

  • Source → Ignition:字节码逐条解释执行,常量时间属性仍成立;

  • Ignition → TurboFan:循环达到热点阈值,触发 inline + 死代码消除 + 类型反馈;

  • TurboFan → Native:基于“一旦发现差异即可终止”的等价变换,生成的本地代码可能引入提前退出,从而引入与差异字节位置相关的耗时差。

V8 编译流水的四层带:源码意图为常量时间,但 TurboFan 优化后字节位置与耗时仍可能存在可观测差异。

最终攻击面与原始 strcmp 接近:源码层面是常量时间,执行层面却可能不是。常量时间是可执行代码的属性,不是源码的属性;源码进入 JIT 流水线后,这个前提需要重新验证。

3.影响范围与风险定级

  • 严重程度:中高。同样是 HMAC 校验失去常量时间属性,且具备双重旁路(长度 + JIT),比 #55663 更难凭代码评审发现。

  • 影响范围:所有启用 Nextcloud Talk 通道的 OpenClaw 部署。

  • 特征:是否被 JIT 抹平依赖运行环境(V8 版本、热路径状态、函数大小阈值)。工程上不能把“是否被抹平”当成不可证伪的不确定项;只要源码上保留了被抹平的可能,就视同已被抹平。

4.复现:换字节位置做差分计时

构造两组等长签名样本,长度都恰为 64 字节:

  • 组 C:与正确签名仅首字节不同;

  • 组 D:与正确签名仅末字节不同。

如果手写循环已被 JIT 优化为提前退出形式,则 tC¯<tD¯,且差值随样本量收敛到稳定区间。这是理想常量时间实现里不应出现的差异。组 C/D 与上一节的组 A/B 是正交实验:A/B 暴露长度旁路,C/D 暴露 JIT 抹平。

5.修复策略与回归保证

修复路径与 #55663 同源:用 crypto.timingSafeEqual 替换手写循环,复用等长 padding 模式将长度判断后置。修复后的控制流与 LINE 修复图 右半边一致(padding → 无条件 timingSafeEqual → 长度后置判断),不再重复绘制。

源码补一段注释说明 expected 侧固定为 HMAC-SHA256 Base64 digest(恒为 64 字符),为何不能回退到手写循环。

配套测试新增 95 行用例,覆盖等长内容相同 / 等长仅首字节不同 / 等长仅末字节不同 / 长度不等 / 全空 / 全 0 等边界,并显式断言受信原语在所有路径都被实际调用。

凭证日志的脱敏缺口(#58162

严重程度:影响范围:所有以腾讯云/阿里云/HuggingFace/Replicate 为模型后端的 OpenClaw 部署

1.现场

OpenClaw 在 debug 级别会把请求/响应的完整 payload 写进日志,这是排障常用手段,也会带来凭证明文落盘风险。

src/logging/redact.ts 为此维护了一组默认脱敏正则,命中即替换:长token保留前6后4(如 AKIDZ8…TEST),短token替换为***。模块上线时已经覆盖了 OpenAI、Anthropic、AWS、GCP 等主流厂商凭证前缀。

但当我们把它对照真实部署里出现过的凭证样本跑一遍时,发现仍有四类未被覆盖。

2.根因

修复前的默认正则集合没覆盖下面四类前缀,而这四类几乎都出现在团队真实部署的日志样本里:

  • 腾讯云AKID 前缀的 SecretId(TencentCloud API Secret ID);

  • 阿里云LTAI 前缀的 AccessKey ID;

  • HuggingFacehf_ 前缀的 access token;

  • Replicater8_ 前缀的 API token。

只要部署方以这四家任一作为模型后端、并因排障把日志级别调到 debug(生产侧通常不会触发,但调试态、故障复盘态、运维误操作都会触发),凭证就会以明文落盘,并随日志聚合管线被复制到 ELK / 对象存储 / 监控平台等多个信任域。

一次明文落盘等价于密钥跨多个信任域被复制,从 SOC/合规视角属于高优先级问题。

脱敏管线对照:默认管线遗漏的 4 类前缀(红色 chip)与修复后全量覆盖(统一收敛为 REDACTED)

3.影响范围与风险定级

  • 严重程度:中。不直接构成 RCE 或未授权访问,但属于凭证以明文穿透日志边界。SOC/审计语境下是高优先级合规问题。

  • 影响范围:所有以腾讯云、阿里云、HuggingFace、Replicate 为模型后端的 OpenClaw 用户。

  • 次生风险:日志的传播链通常远长于代码。一次明文落盘意味着同一份密钥在多个保留期、多个访问主体之间静默复制,撤销代价远高于初始泄漏成本。

4.复现:四步落到日志里

  1. 在 OpenClaw 配置中以四家厂商任一作为模型后端;

  2. 临时把日志级别调高到 debug

  3. 触发一次正常推理调用;

  4. 在日志中检索凭证前缀(AKIDLTAIhf_r8_),观察是否仍以明文出现,或已被替换为 [REDACTED:*]

5.修复策略与回归保证

修复初版比较直接:往默认脱敏正则集合追加上述四类前缀,模式统一为 \b<prefix>[A-Za-z0-9]{10,}\b。但合入后社区报告了一类过度脱敏:Akidaakidney 这样的英文单词在大小写不敏感匹配下会被误判成腾讯云 SecretId。这是脱敏规则的经典权衡:

脱敏规则的真实质量是三个轴的联合函数:召回率、误报率、业务可读性。

召回率:是否覆盖了所有实际出现的凭证前缀?遗漏即漏洞。

误报率:是否会把正常业务子串误判成凭证?过度遮蔽会迫使运维绕过脱敏层,反而扩大攻击面。

业务可读性:日志在排障侧仍要可读;过度遮蔽与遮蔽遗漏同样不可接受。

Gateway 节点状态外层 Map 的内存长尾治理(#58179

严重程度:影响范围:所有以网关模式(gateway.mode=local 或集群模式)运行的 OpenClaw 部署

1.现场

前三项 PR 都能在静态评审里识别出来,前提是评审者具备相应的密码学或合规经验。

#58179 属于运行时状态泄漏:短周期测试通常看不出来,只有在节点反复接入、断开的场景里,RSS 才会随时间持续爬升,最终触发 OOM。

2.根因

OpenClaw gateway 维护了一份按节点(node)粒度组织的 pending work 状态集,结构是两级 Map:

  • 外层 stateByNodeId: Map<nodeId, NodeState>

  • 内层 NodeState.itemsById: Map<itemId, Item>

修复前的代码在两条路径上正确清理了内层条目(acknowledge 删除最后一个显式条目、drain 时过期条目被清空),但从未在内层为空时回收外层 stateByNodeId 中的对应键。

“内层为空”与“外层不存在”被错误地合并为同一种状态——GC 意义上合并了,数据结构语义上没合并。

正常长连接节点不会触发这个问题。但部署里一旦出现大量短命节点(临时连接、推一条任务、ack 完即断开),每个这样的节点都会在 stateByNodeId 里留下一个空壳条目:

  • 外层键 nodeId 仍占用 hash 表槽位;

  • 对应的 NodeState 对象虽然 itemsById.size === 0,本体仍处在活跃引用链上,无法被 GC;

  • 随时间推移,外层 Map 单调增长,进程 RSS 缓慢爬升,最终触发 OOM。

左:修复前后 stateByNodeId 的内容对照;

右:进程 RSS 时序。

修复前 RSS 线性爬升直至 OOM,修复后贴近基线

3.影响范围与风险定级

  • 严重程度:中。慢速内存泄漏,影响长跑稳定性而非即时可用性;但 OOM 一旦触发即是可用性归零。

  • 影响范围:所有以网关模式运行的 OpenClaw 部署,节点连接抖动场景下尤为显著。

  • 长跑特征:单元测试运行周期不足以让外层 Map 增长到可观测水位,监控视图上也很难找到一个明确的“泄漏起点”。

4.复现

启动 gateway 进程,模拟 N 万次一次性节点:每个节点接入、提交一条 pending work、立即 ack 后断开。观察进程 RSS:

RSS(t)≈RSS0+α⋅N(t)(修复前)RSS(t)≈RSS0(修复后,ack 完成回落至基线)

修复前 RSS 与累计经过的节点数 N(t) 严格线性相关;修复后 RSS 维持在基线附近,每轮 ack 完成即回落,与上图右半边的曲线吻合。

5.修复策略与回归保证

修复点放在会令内层变空的两条调用栈末尾:acknowledge 删除最后一个显式条目,以及 drain 时过期条目被清空(pruneExpired 路径)。其余分支(部分 ack、新增 item、状态更新等)即便接触到内层也不会令其变空,不必触发 prune,避免引入无效开销:

function pruneStateIfEmpty(  stateByNodeId: Map<string, NodeState>,  nodeId: string,  state: NodeState,): void {  if (state.itemsById.size === 0) {    stateByNodeId.delete(nodeId);  }}

判定流见下图:drain / ack 完成 → 询问 itemsById 是否为空 → 是则从外层移除 → NodeState 异步进入 GC。配套 17 行测试覆盖三种状态分支:内层非空(保留外层)、内层为空但节点仍活跃(保留外层)、内层为空且节点已断开(回收外层),并以 performance.memoryUsage 断言长跑场景下 RSS 收敛在基线附近。

ack/drain 完成后由 pruneStateIfEmpty 在同一调用栈对齐外层 Map 与 NodeState 的生命周期

三条代码评审检查项建议

把四项 PR 抽离具体语境,得到三条可以直接放进 Code Review 清单的检查项。

1.密码学防御原语必须无条件调用

timingSafeEqual 这类原语的安全性不是“调用了就生效”,而是依赖比较过程在任何输入下都被完整执行。常见反模式:

  • 任何短路求值(&&||、提前 return、长度断言、参数早退)都会破坏常量时间属性。

  • 即使写出“形式上常量时间”的手写循环,JIT 优化器也可能在语义等价的前提下把它编译成 early-exit。

  • 评审中遇到密码学等值比较、签名校验、token 比对路径上的手写循环或长度快路径,应当默认替换为 timingSafeEqual 等受信原语。

2.多层嵌套结构必须在同一调用栈上对齐生命周期

只要状态集合用了两层或更多嵌套数据结构(典型如 Map<K, Map<K2, V>>),任何“清空内层”的操作都必须在同一调用栈检查“是否需要从外层移除”。

只清内层不回收外层键,外层 Map 会随短命对象的累积单调增长。这种泄漏在短周期测试里很难暴露,要等节点抖动累计到一定规模才会出现,监控视图上也很难定位泄漏起点。

3.脱敏规则要按真实语料而非假想场景校准

日志脱敏不是加一条正则就完事。一组合理的脱敏规则要在三个指标之间平衡:

  • 召回率:覆盖所有实际出现在日志里的凭证前缀,遗漏即缺口。

  • 误报率:避免把正常业务子串当凭证遮蔽,否则运维会绕过脱敏层去看原始流量,反而扩大攻击面。#58162 的初版把 Akidaakidney 当成腾讯云 SecretId 就是典型案例,后续 commit 改为大小写敏感才修掉。

  • 可读性:过度遮蔽会让日志丧失排障价值。

三个指标之间存在张力。新增前缀时要同步回归已有规则的负样本,避免补丁式扩张把日志打成不可读的状态。

结语

这四项修复覆盖三类常见边界:签名校验、日志脱敏、运行时状态管理。对应到评审动作,就是检查签名比较是否存在时序差异、debug 日志是否覆盖真实凭证前缀、长跑进程是否残留空壳状态。

后续评审按三条检查即可:密码学防御原语必须无条件调用;多层嵌套结构必须在同一调用栈上对齐生命周期;脱敏规则必须按真实语料做正负样本回归。

腾讯团队在 OpenClaw 上游还覆盖网关协议、插件 SDK、多通道适配等其他主线,本文只取其中安全与稳定性方向的一个子集。

参考资料

本文涉及的 OpenClaw 上游 PR

  • PR #55663(LINE && 短路):https://github.com/openclaw/openclaw/pull/55663

  • PR #58097(Nextcloud Talk JIT 抹平):https://github.com/openclaw/openclaw/pull/58097

  • PR #58162(凭证脱敏管线扩充):https://github.com/openclaw/openclaw/pull/58162

  • PR #58179(外层 Map 生命周期对齐):https://github.com/openclaw/openclaw/pull/58179

日志合规与脱敏

  • TencentCloud SecretId 命名规则:https://cloud.tencent.com/document/product/598/40488

  • HuggingFace hf_ access token 规范:https://huggingface.co/docs/hub/security-tokens

< 干货知识推荐 >

幻兽帕鲁 1.0 来了:一键开服,和好友畅玩帕鲁世界

Octop 重磅开源:更智能的自托管Agent助手

腾讯自选股AI投研助理来了