我们的 AI 引擎 Neutron 在加州大学伯克利分校的 CyberGym 基准测试中取得了 97.1% 的成绩。 了解更多

深度解析

深度解析

node-opcua RBAC 绕过:匿名写入 OPC UA(CVE-2026-95860)

node-opcua 忽略命名空间的默认角色权限,匿名 OPC UA 客户端因此可以写入变量并调用方法;已在 2.180.0 中修复。

TL;DR

  • node-opcua 是面向 Node.js 和 TypeScript 的 OPC UA 实现,它不会将命名空间的默认角色权限应用到远程会话。匿名客户端可以写入依赖该默认值的变量并调用方法。
  • 2.42.0 至 2.179.0 版本受影响。修复包含在 2026 年 8 月 30 日发布的 2.180.0 中;公告于 2026 年 10 月 23 日发布。
  • CVSS v3.1 9.1 严重。公告包含概念验证代码,目前没有发现实际利用,截至 2026 年 11 月 11 日该 CVE 未列入 CISA 的 KEV 目录。
  • 请升级到 2.180.0 或更高版本。在此之前,请设置明确的逐节点 RolePermissions 并禁用匿名会话;限制条件见“如何修复?”。

我受影响吗?

以下条件必须全部成立:

  • 你运行的 OPC UA 服务器基于 node-opcua 2.42.0 至 2.179.0。
  • 服务器接受匿名会话,或者攻击者可以获得低权限会话。
  • 命名空间默认值已声明,无论是通过 setDefaultRolePermissions() 还是通过加载的 NodeSet2 文件,并且至少有一个变量或方法依赖该默认值而不是明确的逐节点权限。

公告区分了两种情况。完全没有声明任何角色权限的服务器在设计上就是允许的,公告称其为不安全的默认值,而不是绕过。9.1 漏洞是第二种情况:操作员声明了命名空间默认值,调用成功,但运行时静默忽略它。

系列 受影响版本 修复版本
2.x 2.42.0 至 2.179.0 2.180.0,2026-08-30 发布
2.x 2.42.0 之前 公告称不受影响
1.x 公告未涵盖 未评估

检查已安装的软件包和服务器配置:

npm ls node-opcua node-opcua-server node-opcua-address-space
node -e "console.log(require('node-opcua/package.json').version)"
grep -R "setDefaultRolePermissions" --include='*.js' --include='*.ts' .
grep -R "allowAnonymous" --include='*.js' --include='*.ts' .

如果代码中调用了 setDefaultRolePermissions(),或者 NodeSet2 文件带有 DefaultRolePermissions 值,并且有节点依赖它,那么你的服务器属于第二种情况。

如何修复?

将每个嵌入 node-opcua 的服务升级到 2.180.0 或更高版本,然后决定服务器如何处理没有适用策略的会话。

  1. 安装修复后的软件包: npm install node-opcua@2.180.0
  2. 如果项目单独固定了服务器和地址空间软件包,请一并更新: npm install node-opcua-server@2.180.0 node-opcua-address-space@2.180.0
  3. 重新构建并重新部署每个 OPC UA 服务器。修复位于运行时,因此只更新锁文件不会改变正在运行的进程。
  4. 确认版本: node -e "console.log(require('node-opcua/package.json').version)"
  5. 决定未解析到任何角色的会话,或在任何层级都没有策略的节点的处理方式。默认仍然是允许。如果希望默认拒绝,请在服务器上设置 unresolvedPermissionPolicy: "deny": javascript const server = new OPCUAServer({ unresolvedPermissionPolicy: "deny" });

如果无法升级,以下措施可以降低暴露。它们不能替代修复。

  1. 为每个重要的变量和方法设置明确的逐节点 RolePermissions。公告确认逐节点权限会被正确执行,包括在受影响版本上。
  2. 使用 allowAnonymous: false 禁用匿名会话,并要求每个客户端使用经过身份验证的会话。
  3. 对于以编程方式创建的命名空间,在 setDefaultRolePermissions() 之后调用 setNamespaceMetaData() 可以让旧版本遵守默认值。对于通过 NodeSet2 加载的命名空间,该调用会抛出 INamespace meta data already exists,因此这条路径不可用。
  4. 在 2.179.0 或更早版本上,不要只依赖 setDefaultRolePermissions()。它记录的策略不会被强制执行路径读取。

根本原因是什么?

OPC UA 分两步解析访问控制。带有明确 RolePermissions 的节点使用这些权限。没有的节点继承命名空间的 DefaultRolePermissions,这是 OPC UA 第 3 部分第 5.2.9 节的要求。第 4.8.3 节随后要求默认拒绝的结果:当操作位未设置时,服务器返回 Bad_UserAccessDenied。node-opcua 颠倒了这个默认值,并断开了本应一致的两条路径。

第一个缺陷是默认值本身。当没有策略到达检查时,辅助函数返回所有权限:

function getPermissionForRole(
    rolePermissions: RolePermissionType[] | null,
    role: NodeId
): PermissionFlag {
    if (rolePermissions === null) {
        return allPermissions; // null means "grant everything" to ANY role, including Anonymous
    }

第二个缺陷解释了为什么策略以 null 到达。Namespace.setDefaultRolePermissions() 将策略写入私有字段,本地 getter 读取该字段。强制执行路径却读取 Server/Namespaces 下的 DefaultRolePermissions 元数据节点:

const defaultRolePermissions = uaNamespaceObject.getChildByName("DefaultRolePermissions") as UAVariable;
if (defaultRolePermissions) {
    const dataValue = defaultRolePermissions.readValue();
    if (dataValue?.statusCode.isGood()) {
        return dataValue.value.value as RolePermissionType[] | null;
    }
}

只有操作员同时调用 setNamespaceMetaData() 时,两条路径才会一致,该调用将字段绑定到节点。对于从 NodeSet2 文件加载的命名空间,该调用会抛出异常。此外,ServerEngine.initialize() 在每次启动时都会将每个命名空间的 DefaultRolePermissions 属性重置为 Null 值,丢弃 nodeset 声明的默认值。

结果是:操作员声明了命名空间策略,声明成功,但强制执行路径将策略解析为 null。这个 null 随后将所有权限授予所有角色,包括 Anonymous。公告的概念验证直接测量了这一差距:在 setDefaultRolePermissions([Operator]) 之后,命名空间 getter 返回策略,而 getApplicableRolePermissions() 返回 null。

如何被利用?

攻击者需要网络访问 OPC UA 端点并获得一个会话。服务器允许匿名会话时,匿名会话就足够了,这在测试和集成环境中很常见。公告的端到端概念验证展示了针对声明了仅 Operator 命名空间默认值的服务器的序列。

  1. 服务器通过 setDefaultRolePermissions() 声明命名空间默认值,或加载声明了该值的 NodeSet2 命名空间,并让某个变量或方法没有逐节点权限。
  2. 攻击者连接到端点并创建匿名会话。
  3. 攻击者写入依赖命名空间默认值的变量,例如过程设定值。
  4. 强制执行路径将默认值解析为 null,返回 allPermissions,写入成功。
  5. 攻击者调用同一命名空间中的绑定方法,例如关机或重置方法。方法没有单独的访问级别门控,绑定方法默认可执行。

公告概念验证的客户端部分很短:

const session = await client.createSession({ type: UserTokenType.Anonymous });
await session.write({
    nodeId: variableNodeId,
    attributeId: AttributeIds.Value,
    value: { value: { dataType: DataType.Double, value: 999.0 } }
});
await session.call({ objectId: parentNodeId, methodId: methodNodeId, inputArguments: [] });

公告还发布了一个 NodeSet2 变体,其中 setNamespaceMetaData() 会抛出异常,因此在受影响版本上没有任何配置可以激活命名空间默认值。该变体最清楚地表明,在升级之前,逐节点权限是唯一可靠的控制。

攻击如何展开?

配置的策略写入一个位置,却从另一个位置读取,因此强制执行路径看不到策略并授予一切。

flowchart LR A["操作员通过 setDefaultRolePermissions
声明命名空间默认值"] --> B["策略存储在命名空间
字段中"] B --> C["强制执行路径读取
DefaultRolePermissions 节点"] C --> D["节点为 Null 或不存在,
策略解析为 null"] D --> E["getPermissionForRole(null)
返回 allPermissions"] F["匿名 OPC UA 会话"] --> E E --> G["允许写入变量和
调用绑定方法"] G --> H["设定值被修改,
方法被执行"] style E fill:#f8d7da

攻击者能做什么?

匿名攻击者可以写入操作员认为受命名空间默认值保护的变量,并调用方法。这包括过程设定值、安全参数以及关闭、重启或重新配置设备的方法。公告还授予读取权限,因此攻击者可以先检查这些值。

条件是影响的一部分:

  • 服务器必须可通过网络访问,并且必须接受匿名会话,或者给攻击者一个角色不在命名空间默认值中的会话。
  • 必须声明命名空间默认值,并且目标节点依赖它。带有明确 RolePermissions 的节点会被正确执行,不受影响。
  • 部署决定可用性影响。公告评分为 A:N,因为影响可用性的方法取决于部署,并指出关闭或角色管理方法可以使 A:H 可达。

完全没有声明策略的服务器保持允许状态,不在此漏洞的范围内。

为什么修复有效?

修复使两条解析路径一致。当元数据节点不存在或带有 Null 值时,强制执行路径现在回退到 setDefaultRolePermissions() 写入的命名空间字段:

     const namespaces = namespace.addressSpace.rootFolder?.objects?.server?.namespaces;
-    if (!namespaces) {
-        return null;
-    }
-    const uaNamespaceObject = namespaces.getChildByName(namespace.namespaceUri);
-    if (!uaNamespaceObject) {
-        return null;
-    }
-    const defaultUserRolePermissions = uaNamespaceObject.getChildByName("DefaultUserRolePermissions") as UAVariable;
-    if (defaultUserRolePermissions) {
-        const dataValue = defaultUserRolePermissions.readValue();
-        if (dataValue?.statusCode.isGood() && dataValue.value.value && dataValue.value.value.length > 0) {
-            return dataValue.value.value as RolePermissionType[];
+    const uaNamespaceObject = namespaces?.getChildByName(namespace.namespaceUri);
+    if (uaNamespaceObject) {
+        // DefaultUserRolePermissions is the user-specific policy and wins when it carries a value.
+        const defaultUserRolePermissions = uaNamespaceObject.getChildByName("DefaultUserRolePermissions") as UAVariable;
+        if (defaultUserRolePermissions) {
+            const dataValue = defaultUserRolePermissions.readValue();
+            if (dataValue?.statusCode.isGood() && dataValue.value.value && dataValue.value.value.length > 0) {
+                return dataValue.value.value as RolePermissionType[];
+            }
         }
-    }
-    const defaultRolePermissions = uaNamespaceObject.getChildByName("DefaultRolePermissions") as UAVariable;
-    if (defaultRolePermissions) {
-        const dataValue = defaultRolePermissions.readValue();
-        if (dataValue?.statusCode.isGood()) {
-            return dataValue.value.value as RolePermissionType[] | null;
+        const defaultRolePermissions = uaNamespaceObject.getChildByName("DefaultRolePermissions") as UAVariable;
+        if (defaultRolePermissions) {
+            const dataValue = defaultRolePermissions.readValue();
+            const value = dataValue?.value?.value as RolePermissionType[] | null | undefined;
+            if (dataValue?.statusCode.isGood() && value !== null && value !== undefined) {
+                return value;
+            }
         }
     }
-    return null;
+
+    return namespace.getDefaultRolePermissions();
 }

同一个提交阻止服务器在启动时覆盖已声明的默认值。ServerEngine.initialize() 现在只初始化没有值的属性,因此 NodeSet2 声明的默认值会保留:

-                    for (const ns of namespacesNode.getComponents()) {
-                        const defaultUserRolePermissions = ns.getChildByName("DefaultUserRolePermissions") as UAVariable | null;
-                        if (defaultUserRolePermissions) {
-                            defaultUserRolePermissions.setValueFromSource({ dataType: DataType.Null });
-                        }
-                        const defaultRolePermissions = ns.getChildByName("DefaultRolePermissions") as UAVariable | null;
-                        if (defaultRolePermissions) {
-                            defaultRolePermissions.setValueFromSource({ dataType: DataType.Null });
-                        }
+                    const initializeIfUnset = (variable: UAVariable | null) => {
+                        if (!variable) {
+                            return;
+                        }
+                        const dataValue = variable.readValue();
+                        const value = dataValue?.value?.value;
+                        const hasValue = dataValue?.statusCode.isGood() && value !== null && value !== undefined;
+                        if (!hasValue) {
+                            variable.setValueFromSource({ dataType: DataType.Null });
+                        }
+                    };
+                    for (const ns of namespacesNode.getComponents()) {
+                        initializeIfUnset(ns.getChildByName("DefaultUserRolePermissions") as UAVariable | null);
+                        initializeIfUnset(ns.getChildByName("DefaultRolePermissions") as UAVariable | null);
                     }

对于已声明策略的情况,修复是完整的:配置的默认值现在会到达强制执行路径,逐节点策略仍然优先。它不会改变第一种情况。没有声明任何策略的服务器保持允许解析,因为发布说明指出,两者都不配置的服务器的默认行为不变。新的 unresolvedPermissionPolicy 选项是可选的默认拒绝控制,默认值为 "allow"。

如何检查是否已遭入侵?

没有针对此漏洞发布入侵指标。如果攻击者恢复了旧值,成功的写入可能在值历史中不可见,而且许多 OPC UA 服务器不保留按会话的审计记录。你可以检查:

  1. 在服务器日志或审计事件中查找匿名会话,尤其是设定值、模式或配置变更前后。
  2. 检查依赖命名空间默认值的变量的值和时戳。在已知维护窗口之外的变化是线索。
  3. 检查服务器配置中的 allowAnonymous: true,以及你认为已生效的命名空间默认值声明。
  4. 如果服务器写入 OPC UA 审计事件,搜索 Anonymous 以及对敏感节点的 Call 或 Write。

干净的日志不能证明服务器未被访问。它只意味着服务器没有记录访问。

时间线

  • 2026-08-28:修复以 PR #1580、提交 0261e873 合并。
  • 2026-08-30:包含修复的 node-opcua 2.180.0 发布。
  • 2026-10-23:GitHub 公告 GHSA-xggf-m53x-jc3f 发布。
  • 2026-11-03:GitHub CNA 发布 CVE-2026-95860。

修复在公开公告前 54 天发布,为未更新的部署留下了很长的时间窗口
图 1:从修复到公开公告。来源:node-opcua 发布说明和 GitHub 公告,2026 年 11 月 11 日检查

常见问题

这会影响所有 node-opcua 服务器吗?

不会。完全没有声明角色权限的服务器属于公告的第一种情况,公告称其为有文档记录的允许行为,而不是绕过。声明了命名空间默认值并依赖它保护某些节点的服务器才是受影响的情况。

我从未调用 setDefaultRolePermissions,还会受影响吗?

可能。NodeSet2 文件可以声明 DefaultRolePermissions,而在受影响版本上服务器会在启动时重置该值。请检查加载的 nodeset 以及是否有节点依赖命名空间默认值。

需要匿名访问吗?

不需要,但这是最简单的路径。低权限的已认证会话在其角色不在命名空间默认值中时也会受益。禁用匿名会话可以降低暴露,但不能关闭该漏洞。

它在实际环境中被利用了吗?

截至 2026 年 11 月 11 日没有已知利用。公告包含概念验证代码,CISA 的 SSVC 块将利用记录为 poc,该 CVE 不在 CISA 的 KEV 目录中。

2.180.0 会让我的服务器默认拒绝吗?

不会。它让已声明的命名空间默认值应用到远程会话。没有策略的服务器仍然允许,除非你设置 unresolvedPermissionPolicy: "deny",发布说明将其记录为构造函数选项。

我应该更新哪些软件包?

将 node-opcua 更新到 2.180.0 或更高版本。如果项目单独固定了 node-opcua-server 或 node-opcua-address-space,请更新到相同版本,使运行时和地址空间一致。

使用 Ostorlab 发现暴露的 OPC UA 端点

使用 Ostorlab Attack Surface Discovery 清点你拥有的面向互联网的主机和服务,从而了解哪些 OPC UA 端点可以访问、需要优先更新。它映射暴露的服务,并不检测此 CVE。

参考文献

  • GitHub 公告 GHSA-xggf-m53x-jc3f,“node-opcua: Namespace-Level RBAC Fail-Open”,2026-10-23 发布,包含根本原因分析和 PoC-A、PoC-B、PoC-C:https://github.com/node-opcua/node-opcua/security/advisories/GHSA-xggf-m53x-jc3f(2026 年 11 月 11 日检查)
  • CVE-2026-95860 记录,GitHub CNA,2026-11-03 发布:https://www.cve.org/CVERecord?id=CVE-2026-95860(2026 年 11 月 11 日检查)
  • 修复 PR #1580,“honour namespace default role permissions on the enforcement path”,2026-08-28 合并:https://github.com/node-opcua/node-opcua/pull/1580(2026 年 11 月 11 日检查)
  • 修复提交 0261e873,2026-08-28:https://github.com/node-opcua/node-opcua/commit/0261e873900800983c622e01120f484dc6063698(2026 年 11 月 11 日检查)
  • node-opcua 2.180.0 发布说明,2026-08-30 发布,包含行为变更和 unresolvedPermissionPolicy 选项:https://github.com/node-opcua/node-opcua/releases/tag/v2.180.0(2026 年 11 月 11 日检查)
  • node-opcua 软件包页面,截至 2026-11-10 的一周下载量为 93,781:https://www.npmjs.com/package/node-opcua(2026 年 11 月 11 日检查)
  • node-opcua-server 软件包页面,同一周下载量为 131,431:https://www.npmjs.com/package/node-opcua-server(2026 年 11 月 11 日检查)
  • OPC UA 第 3 部分第 5.2.9 节关于 RolePermissions 继承:https://reference.opcfoundation.org/Core/Part3/v105/docs/5.2.9(2026 年 11 月 11 日检查)
  • OPC UA 第 3 部分第 4.8.3 节关于默认拒绝的权限评估:https://reference.opcfoundation.org/Core/Part3/v105/docs/4.8.3(2026 年 11 月 11 日检查)
  • CISA 的 Known Exploited Vulnerabilities 目录,版本 2026.11.10,未列出 CVE-2026-95860:https://www.cisa.gov/known-exploited-vulnerabilities-catalog(2026 年 11 月 11 日检查)
  • 图 1,修复到公告的时间线:日期来自上述修复提交、2.180.0 和公告,2026 年 11 月 11 日检查。