Contournement RBAC de node-opcua : écritures anonymes sur OPC UA (CVE-2026-95860)
node-opcua ignore les permissions de rôle par défaut, laissant un client OPC UA anonyme écrire des variables et appeler des méthodes ; corrigé en 2.180.0.
En bref
- node-opcua, la pile OPC UA pour Node.js et TypeScript, n'applique pas les permissions de rôle par défaut d'un espace de noms aux sessions distantes. Un client anonyme peut écrire des variables et appeler des méthodes qui dépendent de ce défaut.
- Les versions 2.42.0 à 2.179.0 sont concernées. Le correctif est dans 2.180.0, publié le 30 août 2026 ; l'avis a été publié le 23 octobre 2026.
- CVSS v3.1 9,1 Critique. L'avis contient du code de preuve de concept, aucune exploitation dans la nature n'est connue, et la CVE n'est pas dans le catalogue KEV de la CISA au 11 novembre 2026.
- Mettez à niveau vers 2.180.0 ou une version ultérieure. En attendant, définissez des RolePermissions explicites par nœud et désactivez les sessions anonymes ; voir « Comment corriger ? » pour les limites.
Suis-je concerné ?
Toutes ces conditions doivent être réunies :
- Vous exploitez un serveur OPC UA construit sur node-opcua 2.42.0 à 2.179.0.
- Le serveur accepte les sessions anonymes, ou un attaquant peut obtenir une session à faibles privilèges.
- Un défaut d'espace de noms est déclaré, soit avec
setDefaultRolePermissions(), soit par un fichier NodeSet2 chargé, et au moins une variable ou méthode dépend de ce défaut au lieu de permissions explicites par nœud.
L'avis sépare deux cas. Un serveur qui ne déclare aucune permission de rôle est permissif par conception, et l'avis parle d'un défaut non sécurisé plutôt que d'un contournement. La faille 9,1 est le second cas : un opérateur déclare un défaut d'espace de noms, l'appel réussit, et l'exécution l'ignore silencieusement.
| Branche | Versions concernées | Version corrigée |
|---|---|---|
| 2.x | 2.42.0 à 2.179.0 | 2.180.0, publiée le 2026-08-30 |
| 2.x | avant 2.42.0 | Non concernées selon l'avis |
| 1.x | Non couvertes par l'avis | Non évaluées |
Vérifiez les paquets installés et la configuration du serveur :
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' .
Un appel à setDefaultRolePermissions() dans votre code, ou un fichier NodeSet2 avec une valeur DefaultRolePermissions, signifie que votre serveur est dans le second cas si un nœud en dépend.
Comment corriger ?
Mettez à niveau chaque service qui embarque node-opcua vers 2.180.0 ou une version ultérieure, puis décidez comment le serveur traite les sessions sans politique applicable.
- Installez le paquet corrigé :
npm install node-opcua@2.180.0 - Mettez à jour les paquets serveur et espace de noms si votre projet les épingle séparément :
npm install node-opcua-server@2.180.0 node-opcua-address-space@2.180.0 - Reconstruisez et redéployez chaque serveur OPC UA. Le correctif est dans l'exécution, donc une simple mise à jour du fichier de verrouillage ne change pas un processus en cours.
- Confirmez la version :
node -e "console.log(require('node-opcua/package.json').version)" - Décidez la politique pour les sessions qui ne correspondent à aucun rôle, ou les nœuds sans politique à aucun niveau. Le défaut reste permissif. Définissez
unresolvedPermissionPolicy: "deny"sur le serveur si vous voulez un comportement fermé par défaut :javascript const server = new OPCUAServer({ unresolvedPermissionPolicy: "deny" });
Si vous ne pouvez pas mettre à niveau, ces mesures réduisent l'exposition. Elles ne remplacent pas le correctif.
- Définissez des
RolePermissionsexplicites par nœud sur chaque variable et méthode qui compte. L'avis confirme que les permissions par nœud sont correctement appliquées, y compris sur les versions vulnérables. - Désactivez les sessions anonymes avec
allowAnonymous: false, et exigez une session authentifiée pour chaque client. - Pour un espace de noms créé par programme, appeler
setNamespaceMetaData()aprèssetDefaultRolePermissions()fait respecter le défaut par les anciennes versions. L'appel lèveINamespace meta data already existssur les espaces de noms chargés par NodeSet2, donc cette voie est fermée pour ceux-ci. - Ne vous fiez pas à
setDefaultRolePermissions()seul sur 2.179.0 ou antérieur. Il enregistre une politique que le chemin d'application ne lit pas.
Quelle est la cause racine ?
OPC UA résout le contrôle d'accès en deux étapes. Un nœud avec des RolePermissions explicites les utilise. Un nœud sans ces permissions hérite des DefaultRolePermissions de l'espace de noms, comme l'exige la section 5.2.9 de la partie 3 d'OPC UA. La section 4.8.3 exige ensuite un résultat par défaut de refus : lorsque les bits d'opération ne sont pas définis, le serveur renvoie Bad_UserAccessDenied. node-opcua a inversé ce défaut et déconnecté les deux chemins qui auraient dû s'accorder.
Le premier bug est le défaut lui-même. Quand aucune politique n'atteignait la vérification, l'assistant renvoyait toutes les permissions :
function getPermissionForRole(
rolePermissions: RolePermissionType[] | null,
role: NodeId
): PermissionFlag {
if (rolePermissions === null) {
return allPermissions; // null means "grant everything" to ANY role, including Anonymous
}
Le second bug explique pourquoi la politique arrivait sous forme de null. Namespace.setDefaultRolePermissions() écrivait la politique dans un champ privé, et le getter local lisait ce champ. Le chemin d'application lisait à la place le nœud de métadonnées DefaultRolePermissions sous Server/Namespaces :
const defaultRolePermissions = uaNamespaceObject.getChildByName("DefaultRolePermissions") as UAVariable;
if (defaultRolePermissions) {
const dataValue = defaultRolePermissions.readValue();
if (dataValue?.statusCode.isGood()) {
return dataValue.value.value as RolePermissionType[] | null;
}
}
Les deux chemins ne se rejoignaient que si l'opérateur appelait aussi setNamespaceMetaData(), qui lie le champ au nœud. Cet appel lève une exception sur un espace de noms chargé depuis un fichier NodeSet2. En plus, ServerEngine.initialize() réinitialisait à chaque démarrage la propriété DefaultRolePermissions de chaque espace de noms à une valeur Null, supprimant un défaut déclaré par un nodeset.
Résultat : un opérateur déclare une politique d'espace de noms, la déclaration réussit, et le chemin d'application résout la politique à null. Le null accorde alors toutes les permissions à tous les rôles, y compris Anonymous. La preuve de concept de l'avis mesure directement l'écart : après setDefaultRolePermissions([Operator]), le getter de l'espace de noms renvoie la politique tandis que getApplicableRolePermissions() renvoie null.
Comment la faille est-elle exploitée ?
L'attaquant a besoin d'un accès réseau au point de terminaison OPC UA et d'une session. Une session anonyme suffit lorsque le serveur en autorise une, ce qui est courant dans les environnements de test et d'intégration. La preuve de concept de bout en bout de l'avis montre la séquence contre un serveur qui a déclaré un défaut d'espace de noms réservé à Operator.
- Le serveur déclare un défaut d'espace de noms avec
setDefaultRolePermissions(), ou charge un espace de noms NodeSet2 qui en déclare un, et laisse une variable ou une méthode sans permissions par nœud. - L'attaquant se connecte au point de terminaison et crée une session anonyme.
- L'attaquant écrit la variable qui dépend du défaut d'espace de noms, par exemple une consigne de procédé.
- Le chemin d'application résout le défaut à
null, renvoieallPermissions, et l'écriture réussit. - L'attaquant appelle une méthode liée dans le même espace de noms, par exemple une méthode d'arrêt ou de réinitialisation. Les méthodes n'ont pas de barrière de niveau d'accès séparée, et une méthode liée est exécutable par défaut.
Le côté client de la preuve de concept de l'avis est court :
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: [] });
L'avis publie aussi une variante NodeSet2 où setNamespaceMetaData() lève une exception, donc aucune configuration n'active le défaut d'espace de noms sur la version vulnérable. Cette variante montre le plus clairement que les permissions par nœud sont le seul contrôle fiable avant la mise à niveau.
Comment se déroule l'attaque ?
La politique configurée est écrite à un endroit et lue à un autre, donc le chemin d'application ne voit aucune politique et accorde tout.
avec setDefaultRolePermissions"] --> B["La politique est stockée dans
un champ de l'espace de noms"] B --> C["L'application lit le nœud
DefaultRolePermissions"] C --> D["Le nœud est Null ou absent,
donc la politique vaut null"] D --> E["getPermissionForRole(null)
renvoie allPermissions"] F["Session OPC UA anonyme"] --> E E --> G["Écriture de Variables et
appel de méthodes liées autorisés"] G --> H["Consignes modifiées,
méthodes exécutées"] style E fill:#f8d7da
Que peut faire un attaquant ?
Un attaquant anonyme peut écrire des variables et appeler des méthodes que l'opérateur croyait protégées par le défaut d'espace de noms. Cela inclut des consignes de procédé, des paramètres de sécurité et des méthodes qui arrêtent, redémarrent ou reconfigurent un équipement. L'avis accorde aussi la lecture, donc l'attaquant peut d'abord inspecter les valeurs.
Les conditions font partie de l'impact :
- Le serveur doit être joignable sur le réseau, et il doit accepter une session anonyme ou donner à l'attaquant une session avec un rôle que le défaut d'espace de noms n'accorde pas.
- Un défaut d'espace de noms doit être déclaré, et le nœud ciblé doit en dépendre. Un nœud avec des
RolePermissionsexplicites est correctement appliqué et n'est pas concerné. - Le déploiement détermine l'impact sur la disponibilité. L'avis note
A:Ncar une méthode qui affecte la disponibilité dépend du déploiement, et précise qu'une méthode d'arrêt ou d'administration des rôles rendA:Hatteignable.
Un serveur qui ne déclare aucune politique reste permissif et sort du périmètre de cette faille.
Pourquoi le correctif fonctionne-t-il ?
Le correctif fait s'accorder les deux chemins de résolution. Lorsque le nœud de métadonnées est absent ou porte une valeur Null, le chemin d'application retombe désormais sur le champ d'espace de noms que setDefaultRolePermissions() écrit :
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();
}
Le même commit empêche le serveur d'écraser un défaut déclaré au démarrage. ServerEngine.initialize() n'initialise plus que les propriétés sans valeur, donc un défaut déclaré par un NodeSet2 survit :
- 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);
}
Le correctif est complet pour le cas de la politique déclarée : un défaut configuré atteint désormais l'application, et une politique par nœud le remplace toujours. Il ne change pas le premier cas. Un serveur qui ne déclare aucune politique conserve la résolution permissive, car les notes de version indiquent que le comportement par défaut d'un serveur qui ne configure ni l'un ni l'autre est inchangé. La nouvelle option unresolvedPermissionPolicy est le contrôle facultatif de refus par défaut, et sa valeur par défaut est "allow".
Comment vérifier si j'ai été compromis ?
Aucun indicateur de compromission n'est publié pour cette faille. Une écriture réussie peut être invisible dans l'historique des valeurs si l'attaquant restaure l'ancienne, et de nombreux serveurs OPC UA ne conservent aucune trace d'audit par session. Ce que vous pouvez vérifier :
- Cherchez des sessions anonymes dans les journaux ou les événements d'audit du serveur, surtout autour des changements de consignes, de modes ou de configuration.
- Examinez les valeurs et les horodatages des variables qui dépendent d'un défaut d'espace de noms. Un changement hors d'une fenêtre de maintenance connue est une piste.
- Vérifiez la configuration du serveur pour
allowAnonymous: trueet pour un défaut d'espace de noms déclaré que vous croyiez appliqué. - Si votre serveur écrit des événements d'audit OPC UA, cherchez
Anonymouset les opérationsCallouWritesur des nœuds sensibles.
Un journal propre ne prouve pas que le serveur n'a pas été accédé. Il signifie que le serveur n'a pas enregistré l'accès.
Chronologie
- 2026-08-28 : le correctif est fusionné via la PR #1580, commit 0261e873.
- 2026-08-30 : node-opcua 2.180.0 est publié avec le correctif.
- 2026-10-23 : l'avis GitHub GHSA-xggf-m53x-jc3f est publié.
- 2026-11-03 : CVE-2026-95860 est publiée par le CNA GitHub.
FAQ
Cette faille concerne-t-elle tous les serveurs node-opcua ?
Non. Un serveur qui ne déclare aucune permission de rôle se trouve dans le premier cas de l'avis, qu'il qualifie de comportement permissif documenté plutôt que de contournement. Un serveur qui déclare un défaut d'espace de noms et s'appuie dessus pour certains nœuds est le cas concerné.
Je n'ai jamais appelé setDefaultRolePermissions. Suis-je concerné ?
Peut-être. Un fichier NodeSet2 peut déclarer DefaultRolePermissions, et le serveur réinitialisait cette valeur au démarrage sur les versions vulnérables. Vérifiez les nodesets chargés et si un nœud dépend du défaut d'espace de noms.
L'accès anonyme est-il nécessaire ?
Non, mais c'est le chemin le plus simple. Une session authentifiée à faibles privilèges en profite aussi lorsque son rôle n'est pas dans le défaut d'espace de noms. Désactiver les sessions anonymes réduit l'exposition sans fermer la faille.
La faille a-t-elle été exploitée dans la nature ?
Aucune exploitation n'est connue au 11 novembre 2026. L'avis contient du code de preuve de concept, le bloc SSVC de la CISA enregistre l'exploitation comme poc, et la CVE n'est pas dans le catalogue KEV de la CISA.
La version 2.180.0 rend-elle mon serveur fermé par défaut ?
Non. Elle fait appliquer un défaut d'espace de noms déclaré aux sessions distantes. Un serveur sans politique reste permissif, sauf si vous définissez unresolvedPermissionPolicy: "deny", que les notes de version documentent comme option du constructeur.
Quels paquets dois-je mettre à jour ?
Mettez à jour node-opcua vers 2.180.0 ou une version ultérieure. Si votre projet épingle node-opcua-server ou node-opcua-address-space séparément, mettez-les à jour vers la même version pour que l'exécution et l'espace de noms s'accordent.
Trouver les points de terminaison OPC UA exposés avec Ostorlab
Utilisez Ostorlab Attack Surface Discovery pour inventorier les hôtes et services exposés à Internet que vous possédez, afin de savoir quels points de terminaison OPC UA sont joignables et doivent être mis à jour en premier. Il cartographie les services exposés ; il ne détecte pas cette CVE.
Références
- Avis GitHub GHSA-xggf-m53x-jc3f, « node-opcua: Namespace-Level RBAC Fail-Open », publié le 2026-10-23, avec l'analyse de la cause racine et les PoC-A, PoC-B et PoC-C : https://github.com/node-opcua/node-opcua/security/advisories/GHSA-xggf-m53x-jc3f (vérifié le 11 novembre 2026)
- L'enregistrement CVE-2026-95860, CNA GitHub, publié le 2026-11-03 : https://www.cve.org/CVERecord?id=CVE-2026-95860 (vérifié le 11 novembre 2026)
- PR de correctif #1580, « honour namespace default role permissions on the enforcement path », fusionnée le 2026-08-28 : https://github.com/node-opcua/node-opcua/pull/1580 (vérifié le 11 novembre 2026)
- Commit de correctif 0261e873, 2026-08-28 : https://github.com/node-opcua/node-opcua/commit/0261e873900800983c622e01120f484dc6063698 (vérifié le 11 novembre 2026)
- Notes de version node-opcua 2.180.0, publiées le 2026-08-30, avec les changements de comportement et l'option
unresolvedPermissionPolicy: https://github.com/node-opcua/node-opcua/releases/tag/v2.180.0 (vérifié le 11 novembre 2026) - La page du paquet node-opcua, 93 781 téléchargements hebdomadaires pour la semaine se terminant le 2026-11-10 : https://www.npmjs.com/package/node-opcua (vérifié le 11 novembre 2026)
- La page du paquet node-opcua-server, 131 431 téléchargements hebdomadaires la même semaine : https://www.npmjs.com/package/node-opcua-server (vérifié le 11 novembre 2026)
- OPC UA partie 3, section 5.2.9 sur l'héritage des RolePermissions : https://reference.opcfoundation.org/Core/Part3/v105/docs/5.2.9 (vérifié le 11 novembre 2026)
- OPC UA partie 3, section 4.8.3 sur l'évaluation des permissions par défaut de refus : https://reference.opcfoundation.org/Core/Part3/v105/docs/4.8.3 (vérifié le 11 novembre 2026)
- Le catalogue Known Exploited Vulnerabilities de la CISA, version 2026.11.10, qui ne liste pas CVE-2026-95860 : https://www.cisa.gov/known-exploited-vulnerabilities-catalog (vérifié le 11 novembre 2026)
- Figure 1, la chronologie correctif-avis : dates issues du commit de correctif, de la version 2.180.0 et de l'avis ci-dessus, vérifiés le 11 novembre 2026.