node-opcua の RBAC バイパス:OPC UA への匿名書き込み(CVE-2026-95860)
node-opcua は名前空間の既定のロール権限を適用しないため、匿名の OPC UA クライアントが変数を書き込み、メソッドを呼び出せます。2.180.0 で修正されました。
要点
- Node.js と TypeScript 向けの OPC UA スタックである node-opcua は、名前空間の既定のロール権限をリモートセッションに適用しません。匿名クライアントは、その既定値に依存する変数へ書き込み、メソッドを呼び出せます。
- バージョン 2.42.0 から 2.179.0 が影響を受けます。修正は 2026年8月30日に公開された 2.180.0 に含まれ、アドバイザリは 2026年10月23日に公開されました。
- CVSS v3.1 9.1(重大)です。アドバイザリには実証コードが含まれ、実際の悪用は確認されておらず、2026年11月11日時点で CISA の KEV カタログにも登録されていません。
- 2.180.0 以降に更新してください。それまでは、ノードごとの明示的な RolePermissions を設定し、匿名セッションを無効にしてください。制限の詳細は「修正方法」を参照してください。
影響を受けますか?
次の条件がすべて当てはまる場合に影響を受けます。
- node-opcua 2.42.0 から 2.179.0 で構築した OPC UA サーバーを運用している。
- サーバーが匿名セッションを受け入れるか、攻撃者が低権限のセッションを取得できる。
- 名前空間の既定値が
setDefaultRolePermissions()または読み込んだ NodeSet2 ファイルで宣言されており、少なくとも1つの変数またはメソッドが、ノードごとの明示的な権限ではなくその既定値に依存している。
アドバイザリは2つのケースを分けています。ロール権限をどこにも宣言していないサーバーは設計上許容的であり、アドバイザリはこれをバイパスではなく安全でない既定値と呼んでいます。9.1 の脆弱性は2つ目のケースです。運用者が名前空間の既定値を宣言し、呼び出しは成功するものの、実行時に黙って無視されます。
| 系列 | 影響を受けるバージョン | 修正バージョン |
|---|---|---|
| 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() 呼び出し、または DefaultRolePermissions 値を持つ NodeSet2 ファイルがある場合、いずれかのノードがそれに依存していれば、サーバーは2つ目のケースです。
修正方法
node-opcua を組み込んだすべてのサービスを 2.180.0 以降に更新し、適用可能なポリシーがないセッションをサーバーがどう扱うかを決めます。
- 修正済みパッケージをインストールします。
npm install node-opcua@2.180.0 - プロジェクトがサーバーとアドレススペースのパッケージを個別に固定している場合は更新します。
npm install node-opcua-server@2.180.0 node-opcua-address-space@2.180.0 - 各 OPC UA サーバーを再ビルドして再デプロイします。修正は実行時に入るため、ロックファイルの更新だけでは実行中のプロセスは変わりません。
- バージョンを確認します。
node -e "console.log(require('node-opcua/package.json').version)" - ロールに解決されないセッション、またはどのレベルでもポリシーがないノードの扱いを決めます。既定は許容的なままです。既定で拒否する動作が必要な場合は、サーバーに
unresolvedPermissionPolicy: "deny"を設定します。javascript const server = new OPCUAServer({ unresolvedPermissionPolicy: "deny" });
更新できない場合は、次の対策で露出を減らしてください。修正の代わりにはなりません。
- 重要なすべての変数とメソッドに、ノードごとの明示的な
RolePermissionsを設定します。アドバイザリは、脆弱なバージョンでもノードごとの権限が正しく適用されることを確認しています。 allowAnonymous: falseで匿名セッションを無効にし、すべてのクライアントに認証済みセッションを要求します。- プログラムで作成した名前空間では、
setDefaultRolePermissions()の後にsetNamespaceMetaData()を呼ぶと、古いバージョンでも既定値が適用されます。NodeSet2 で読み込んだ名前空間ではINamespace meta data already existsが発生するため、その経路は使えません。 - 2.179.0 以前では
setDefaultRolePermissions()だけに頼らないでください。適用経路が読まないポリシーを記録するだけです。
根本原因は何ですか?
OPC UA はアクセス制御を2段階で解決します。明示的な RolePermissions を持つノードはそれを使います。持たないノードは、OPC UA パート3の5.2.9節が要求するように、名前空間の DefaultRolePermissions を継承します。4.8.3節はさらに既定で拒否する結果を要求します。操作ビットが設定されていない場合、サーバーは Bad_UserAccessDenied を返します。node-opcua はこの既定を逆にし、一致すべき2つの経路を切り離しました。
1つ目の不具合は既定値そのものです。ポリシーがチェックに到達しない場合、ヘルパーはすべての権限を返しました。
function getPermissionForRole(
rolePermissions: RolePermissionType[] | null,
role: NodeId
): PermissionFlag {
if (rolePermissions === null) {
return allPermissions; // null means "grant everything" to ANY role, including Anonymous
}
2つ目の不具合は、ポリシーが 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;
}
}
2つの経路は、運用者が setNamespaceMetaData() も呼んだ場合にだけ一致しました。この呼び出しはフィールドをノードに結び付けます。NodeSet2 ファイルから読み込んだ名前空間では例外が発生します。さらに ServerEngine.initialize() は起動のたびに各名前空間の DefaultRolePermissions プロパティを Null 値にリセットし、nodeset が宣言した既定値を破棄していました。
結果として、運用者が名前空間ポリシーを宣言し、宣言は成功するものの、適用経路はポリシーを null に解決します。その null が Anonymous を含むすべてのロールにすべての権限を与えます。アドバイザリの実証コードはこの差を直接測定しています。setDefaultRolePermissions([Operator]) の後、名前空間の getter はポリシーを返しますが、getApplicableRolePermissions() は null を返します。
どのように悪用されますか?
攻撃者には OPC UA エンドポイントへのネットワークアクセスとセッションが必要です。サーバーが匿名セッションを許可していれば、匿名セッションで十分です。これはテストや統合の環境でよくあります。アドバイザリのエンドツーエンド実証コードは、Operator 専用の名前空間既定値を宣言したサーバーに対する手順を示しています。
- サーバーが
setDefaultRolePermissions()で名前空間の既定値を宣言するか、それを宣言する NodeSet2 名前空間を読み込み、変数またはメソッドをノードごとの権限なしで残します。 - 攻撃者がエンドポイントに接続し、匿名セッションを作成します。
- 攻撃者が名前空間の既定値に依存する変数、たとえばプロセスの設定値を書き込みます。
- 適用経路は既定値を
nullに解決し、allPermissionsを返すため、書き込みが成功します。 - 攻撃者が同じ名前空間のバインド済みメソッド、たとえばシャットダウンやリセットのメソッドを呼び出します。メソッドには別個のアクセスレベルゲートがなく、バインド済みメソッドは既定で実行可能です。
アドバイザリの実証コードのクライアント側は短いものです。
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: [] });
アドバイザリは、setNamespaceMetaData() が例外を投げる NodeSet2 の変種も公開しています。そのため、脆弱なバージョンではどの設定でも名前空間の既定値を有効にできません。この変種は、更新前にはノードごとの権限だけが信頼できる制御であることを最も明確に示しています。
攻撃の流れは?
設定されたポリシーは一方の場所に書かれ、別の場所から読まれるため、適用経路はポリシーを見ずにすべてを許可します。
名前空間の既定値を宣言"] --> 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に到達すると注記しています。
ポリシーをどこにも宣言していないサーバーは許容的なままで、この欠陥の対象外です。
修正が機能する理由
修正は2つの解決経路を一致させます。メタデータノードが存在しないか 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);
}
修正は宣言済みポリシーのケースでは完全です。設定された既定値が適用経路に届き、ノードごとのポリシーは引き続き優先されます。1つ目のケースは変わりません。どちらも設定しないサーバーの既定動作は変わらないとリリースノートが述べているため、ポリシーを宣言しないサーバーは許容的な解決のままです。新しい unresolvedPermissionPolicy オプションは既定で拒否するための任意の制御で、既定値は "allow" です。
侵害されたかどうかを確認するには?
この脆弱性に関する侵害指標は公開されていません。攻撃者が元の値を戻せば、成功した書き込みは値の履歴に残らないことがあります。また、多くの OPC UA サーバーはセッションごとの監査証跡を保持しません。確認できる項目は次のとおりです。
- サーバーのログや監査イベントで匿名セッションを探します。特に設定値、モード、構成の変更前後を確認します。
- 名前空間の既定値に依存する変数の値とタイムスタンプを確認します。既知の保守時間外の変更は手がかりです。
- サーバー設定で
allowAnonymous: trueと、適用されていると考えていた宣言済みの名前空間既定値を確認します。 - サーバーが 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 を公開しました。
よくある質問
すべての node-opcua サーバーが影響を受けますか?
いいえ。ロール権限をどこにも宣言していないサーバーはアドバイザリの1つ目のケースで、文書化された許容的な動作であり、バイパスとは呼ばれていません。名前空間の既定値を宣言し、一部のノードでそれに依存するサーバーが影響を受けるケースです。
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日確認。