
Signal K Server 2.0.0 到 2.34.0 存在路径穿越,匿名或低权限即可读取 security.json,拿到 JWT 签名密钥后伪造管理员令牌,再通过 Execute provider 执行命令。CVSS 9.8 不是虚高,前提是这台船用网关暴露在网络上。
受影响的是 Signal K Server 2.0.0 至 2.34.0,以及它依赖的 @signalk/resources-provider 1.0.0 至 1.5.3。修复版本分别是 2.34.0 和 1.5.3。NVD 在 2026 年 11 月 9 日收录并给出 9.8 分,CWE 归类为 CWE-22 路径穿越。
触发点在 v2 REST 路由 GET /signalk/v2/api/resources/:resourceType/:resourceId。官方描述写明,这个 resourceId 被传入 src/api/resources/index.ts,再交给 packages/resources-provider-plugin/src/filestorage.ts 里的 FileStore.getResource(),缺少足够的路径约束,导致服务器能读到的任意 JSON 文件都可被读取。关联的 PUT 和 DELETE 操作还能在存储根目录之外写文件或删文件。
利用条件分两档。启用 allow_readonly 的部署允许匿名读取;没启用的部署需要一个 readonly 用户令牌。读到 security.json 之后,HS256 的 JWT 签名密钥和口令哈希都在里面,攻击者可以伪造管理员令牌。拿到管理员权限后,向 POST /skServer/providers 提交一个 Execute provider,就能以服务器进程的权限执行命令,而且这个配置会跨重启保留下来。
截至 2026 年 11 月 9 日,公开信息未披露在野利用情况,也没有公开的 PoC 代码。NVD 状态是 Received,意味着描述和评分已经给出,但可能还没完成完整的来源核对。

图 1 本文示意图:resourceId 跳出目录后,每一步都踩在官方文档写明的正常功能上。
缺陷落在实现层,不是设计上的主动取舍。路由本身的设计意图很清楚:resourceType 和 resourceId 两个参数定位到资源目录下的某个 JSON 文件。问题在于从 HTTP 参数到文件系统路径之间,少了一次规范化之后的包含性检查。FileStore.getResource() 拿到的是拼接后的路径,它信任调用方已经把路径限制在根目录内,而调用方 src/api/resources/index.ts 只做了参数透传。两层都以为对方会做这件事。
路径穿越的经典形态在这里完全成立。resourceId 里带上 ../ 序列,拼接后的路径就会跳出资源目录。如果实现里只做了字符串层面的前缀比较,那么类似 resources/../../security.json 的输入在规范化之前能通过检查,规范化之后却指向了根目录之外。公开描述没有说明具体用的是哪种校验方式,但明确写了「without adequate path containment」,说明校验存在,只是不够。这是推断,不是原文结论。
触发需要凑齐的条件不多,但每一条都具体。第一,目标文件必须是 JSON,因为 FileStore 的读取路径面向 JSON 资源;security.json 正好是 JSON。第二,服务器进程对目标文件有读权限,security.json 通常就在应用数据目录里,进程自己写得进去也读得出来。第三,调用顺序是先 GET 读文件,再伪造 JWT,再 POST 创建 provider,三步之间有依赖,不能跳。第四,如果部署没开 allow_readonly,第一步就需要一个 readonly 令牌,这抬高了门槛,但没有改变链路本身。
为什么长期没被发现,有一个结构性原因。Signal K 的定位是船上的中央 hub,默认使用场景是局域网内的海图仪、传感器和手机 App 互访。开发者对暴露面的假设是「内网可信」,路径校验的严格程度自然按内网标准来写。这类项目里,资源接口通常被当作便利性功能,而不是安全边界。等到有人把它挂到公网做远程监控,原来的假设就不成立了,但代码没有跟着变。
同类缺陷在文件服务类组件里反复出现,规律很稳定:只要一个接口同时接受「资源标识」和「文件路径」两种语义,就迟早会有人把标识当成路径用。清单里同日的 CVE-2026-123816 是 pyLoad 的 API key 缓存校验问题,CVE-2026-119759 是 TrailBase 的越权对象引用,都属于「校验逻辑和实际使用方式对不上」这一类。这是同类组件的通用规律,不是本案的结论。
还有一个放大器值得单独说:读到的文件是 security.json,不是普通配置。HS256 是对称签名,密钥一旦泄露,攻击者不需要破解任何东西,直接自己签一个管理员令牌就行。如果这里用的是 RS256 并且私钥不落在可读目录,同样的路径穿越只能读到配置,提权链就断在第二步。密钥的存放位置和签名算法选型,把一次文件读取放大成了完整的账户接管。

图 2 本文示意图:接口层以为存储层会约束路径,存储层以为接口层已经做过了。
推演路径拼接顺序(示意代码,说明规范化前后检查点错位如何让 ../ 逃出根目录。)
// 伪代码推演,不是漏洞原文代码
const path = require('path');
const ROOT = '/data/resources';
function getResource(resourceType, resourceId) {
const joined = path.join(ROOT, resourceType, resourceId);
// 常见写法一:规范化之前做前缀比较
if (!joined.startsWith(ROOT)) {
throw new Error('denied');
}
const normalized = path.normalize(joined);
// 此时 normalized 可能已经跳出 ROOT
return normalized;
}
console.log(getResource('charts', '../../security.json'));
// 输出 /data/security.json,前缀检查通过,文件已越界9.8 分的向量是 AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H。逐项翻译:网络可达、攻击复杂度低、不需要身份、不需要受害者配合、影响局限在组件自身、机密性完整性可用性全部失守。这个评分在「组件被直接暴露」的前提下是准确的,因为匿名读取加伪造管理员令牌加命令执行,三项都成立。
但评分假设的是最坏部署。现实里要分三种情况。第一种,Signal K 挂在公网并且开了 allow_readonly,这是最危险的组合,匿名即可走完整条链,今天就得处理。第二种,挂在公网但没开 allow_readonly,攻击者需要一个 readonly 令牌,门槛提高了一档,但 readonly 令牌在船队共享或第三方集成场景里并不罕见。第三种,只监听局域网或者回环地址,攻击者得先进入这个网络,9.8 分里的 AV:N 就要打折扣,实际风险降到中高。
真实攻击路径不长。先请求 GET /signalk/v2/api/resources/../security.json 一类的路径拿到密钥,本地用 HS256 签一个带管理员权限的 JWT,再向 POST /skServer/providers 提交 Execute provider 配置,指定要执行的命令。这条链每一步都是公开文档里的正常功能,不需要内存破坏,不需要绕过任何防护。公开信息未披露实际利用样本,但链路的每一环都在官方描述里写明了。
不受影响的情况也要说清楚。低于 2.0.0 的 Signal K Server 不在受影响区间;已经升到 2.34.0 及以上、并且 resources-provider 在 1.5.3 及以上的部署不受影响。只在本机回环上跑、没有任何外部访问路径的实例,被远程利用的可能性很低,但本地多用户环境仍然要防。另外,如果部署层在前面加了反向代理并且对 /signalk/v2/api/resources/ 做了路径规范化,能挡住一部分变形输入,但这只能算缓解,不能替代升级。

图 3 本文示意图:同样是 9.8 分,公网加匿名读取和纯内网是两件事。
该不该慌,取决于这台服务器有没有公网入口。有公网入口且开了 allow_readonly 的,今天就该动手,不要等维护窗口。只在内网跑的,可以按正常补丁节奏走,但别拖过这个月。判断方法很直接:查监听地址和 allow_readonly 配置,两分钟能查完。
第一步不是升级,是先确认暴露面。把 Signal K 的监听地址、反向代理规则、allow_readonly 开关三项查清楚,同时确认 security.json 里当前的 JWT 密钥是不是已经用了很久。如果实例曾经暴露在公网,升级只能堵住入口,不能收回已经泄露的密钥,必须轮换签名密钥并让现有令牌全部失效。这一步比升级本身更急。
第二步才是升级。Signal K Server 升到 2.34.0,同时确认 @signalk/resources-provider 到 1.5.3。这两个版本要一起到位,只升主程序不升 provider 包,路径校验可能还是旧的。升级之后重启,检查 Execute provider 列表里有没有不认识的条目,有就说明可能已经被利用过。
暂时动不了的,给两个过渡手段。一是在反向代理层对 /signalk/v2/api/resources/ 路径做规范化并拒绝包含 .. 的请求,这是缓解不是修复。二是关掉 allow_readonly,把匿名读取的路堵死,代价是只读客户端要配令牌。两个手段都只能降低门槛,不能消除缺陷,能升级还是要升级。
还有一个容易被忽略的动作:审计 POST /skServer/providers 的历史调用。Execute provider 的配置会持久化,意味着即使入口修好了,之前植入的 provider 还在。查一遍 provider 配置和服务器进程的启动参数,比只看版本号更有意义。
核对版本与暴露面(示意代码,一次查清版本区间、监听地址和 allow_readonly 开关。)
#!/bin/bash
# 排查命令,不是漏洞利用代码
APP_DIR="${1:-/usr/local/lib/node_modules/signalk-server}"
printf 'server version: '
node -p "require('$APP_DIR/package.json').version" 2>/dev/null || echo unknown
printf 'resources-provider: '
node -p "require('$APP_DIR/node_modules/@signalk/resources-provider/package.json').version" 2>/dev/null || echo unknown
printf 'listen: '
grep -RhoE '"port"[[:space:]]*:[[:space:]]*[0-9]+' "$APP_DIR/settings.json" 2>/dev/null || echo 'check settings.json manually'
printf 'allow_readonly: '
grep -o '"allow_readonly"[[:space:]]*:[[:space:]]*[a-z]*' "$APP_DIR/settings.json" 2>/dev/null || echo 'not set'
printf 'security.json: '
find / -name security.json -path '*signalk*' 2>/dev/null | head -n 3先去查 allow_readonly 和监听地址,再决定今晚升不升。如果这个实例曾经在公网待过,升级之外还要轮换 JWT 密钥,否则入口堵了,钥匙还在别人手里。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。