Neutron, nuestro motor de IA, obtuvo un 97.1% en el benchmark CyberGym de UC Berkeley. Más información

Análisis a fondo

Análisis a fondo

node-opcua RBAC Bypass: escrituras anónimas en OPC UA (CVE-2026-95860)

node-opcua ignora los permisos de rol predeterminados, por lo que un cliente OPC UA anónimo puede escribir variables y llamar a métodos; corregido en 2.180.0.

Resumen

  • node-opcua, la pila OPC UA para Node.js y TypeScript, no aplica los permisos de rol predeterminados de un espacio de nombres a las sesiones remotas. Un cliente anónimo puede escribir variables y llamar a métodos que dependen de ese valor predeterminado.
  • Las versiones 2.42.0 a 2.179.0 están afectadas. La corrección está en 2.180.0, publicada el 30 de agosto de 2026; el aviso se publicó el 23 de octubre de 2026.
  • CVSS v3.1 9.1 Crítica. El aviso incluye código de prueba de concepto, no se conoce explotación en entornos reales y el CVE no está en el catálogo KEV de CISA a 11 de noviembre de 2026.
  • Actualice a 2.180.0 o posterior. Hasta entonces, defina RolePermissions explícitos por nodo y desactive las sesiones anónimas; consulte "¿Cómo lo corrijo?" para conocer los límites.

¿Estoy afectado?

Todas estas condiciones deben cumplirse:

  • Usted ejecuta un servidor OPC UA construido sobre node-opcua 2.42.0 a 2.179.0.
  • El servidor acepta sesiones anónimas, o un atacante puede obtener una sesión con pocos privilegios.
  • Hay un valor predeterminado de espacio de nombres declarado, ya sea con setDefaultRolePermissions() o mediante un archivo NodeSet2 cargado, y al menos una variable o método depende de ese valor en lugar de permisos explícitos por nodo.

El aviso separa dos casos. Un servidor que no declara ningún permiso de rol es permisivo por diseño, y el aviso lo llama un valor predeterminado inseguro, no un bypass. El fallo 9.1 es el segundo caso: un operador declara un valor predeterminado de espacio de nombres, la llamada tiene éxito y el tiempo de ejecución lo ignora en silencio.

Rama Versiones afectadas Versión corregida
2.x 2.42.0 a 2.179.0 2.180.0, publicada el 2026-08-30
2.x anterior a 2.42.0 No afectadas según el aviso
1.x No cubiertas por el aviso No evaluadas

Compruebe los paquetes instalados y la configuración del servidor:

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' .

Una llamada a setDefaultRolePermissions() en su código, o un archivo NodeSet2 con un valor DefaultRolePermissions, significa que su servidor está en el segundo caso si algún nodo depende de él.

¿Cómo lo corrijo?

Actualice cada servicio que incorpore node-opcua a 2.180.0 o posterior, y después decida cómo trata el servidor las sesiones sin política aplicable.

  1. Instale el paquete corregido: npm install node-opcua@2.180.0
  2. Actualice los paquetes de servidor y espacio de nombres si su proyecto los fija por separado: npm install node-opcua-server@2.180.0 node-opcua-address-space@2.180.0
  3. Recompile y vuelva a desplegar cada servidor OPC UA. La corrección está en el tiempo de ejecución, así que actualizar solo el archivo de bloqueo no cambia un proceso en marcha.
  4. Confirme la versión: node -e "console.log(require('node-opcua/package.json').version)"
  5. Decida la política para las sesiones que no resuelven a ningún rol, o los nodos sin política en ningún nivel. El valor predeterminado sigue siendo permisivo. Defina unresolvedPermissionPolicy: "deny" en el servidor si quiere un comportamiento cerrado por defecto: javascript const server = new OPCUAServer({ unresolvedPermissionPolicy: "deny" });

Si no puede actualizar, estas medidas reducen la exposición. No sustituyen a la corrección.

  1. Defina RolePermissions explícitos por nodo en cada variable y método que importe. El aviso confirma que los permisos por nodo se aplican correctamente, incluso en las versiones vulnerables.
  2. Desactive las sesiones anónimas con allowAnonymous: false y exija una sesión autenticada para cada cliente.
  3. Para un espacio de nombres creado por programa, llamar a setNamespaceMetaData() después de setDefaultRolePermissions() hace que las versiones antiguas respeten el valor predeterminado. La llamada lanza INamespace meta data already exists en espacios de nombres cargados por NodeSet2, así que esa vía está cerrada para ellos.
  4. No confíe solo en setDefaultRolePermissions() en 2.179.0 o anterior. Registra una política que la ruta de aplicación no lee.

¿Cuál es la causa raíz?

OPC UA resuelve el control de acceso en dos pasos. Un nodo con RolePermissions explícitos los usa. Un nodo sin ellos hereda los DefaultRolePermissions del espacio de nombres, como exige la sección 5.2.9 de la parte 3 de OPC UA. La sección 4.8.3 exige después un resultado de denegación por defecto: cuando los bits de operación no están definidos, el servidor devuelve Bad_UserAccessDenied. node-opcua invirtió ese valor predeterminado y desconectó las dos rutas que deberían haber coincidido.

El primer error es el propio valor predeterminado. Cuando ninguna política llegaba a la comprobación, el ayudante devolvía todos los permisos:

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

El segundo error explica por qué la política llegaba como null. Namespace.setDefaultRolePermissions() escribía la política en un campo privado, y el getter local leía ese campo. La ruta de aplicación leía en su lugar el nodo de metadatos DefaultRolePermissions bajo 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;
    }
}

Las dos rutas solo se encontraban cuando el operador también llamaba a setNamespaceMetaData(), que vincula el campo al nodo. Esa llamada lanza una excepción en un espacio de nombres cargado desde un archivo NodeSet2. Además, ServerEngine.initialize() restablecía en cada arranque la propiedad DefaultRolePermissions de cada espacio de nombres a un valor Null, descartando un valor predeterminado declarado por un nodeset.

El resultado: un operador declara una política de espacio de nombres, la declaración tiene éxito y la ruta de aplicación resuelve la política a null. El null concede entonces todos los permisos a todos los roles, incluido Anonymous. La prueba de concepto del aviso mide la brecha directamente: después de setDefaultRolePermissions([Operator]), el getter del espacio de nombres devuelve la política mientras que getApplicableRolePermissions() devuelve null.

¿Cómo se explota?

El atacante necesita acceso de red al punto de conexión OPC UA y una sesión. Una sesión anónima basta cuando el servidor la permite, algo habitual en entornos de prueba e integración. La prueba de concepto de extremo a extremo del aviso muestra la secuencia contra un servidor que declaró un valor predeterminado de espacio de nombres solo para Operator.

  1. El servidor declara un valor predeterminado de espacio de nombres con setDefaultRolePermissions(), o carga un espacio de nombres NodeSet2 que declara uno, y deja una variable o método sin permisos por nodo.
  2. El atacante se conecta al punto de conexión y crea una sesión anónima.
  3. El atacante escribe la variable que depende del valor predeterminado del espacio de nombres, por ejemplo un valor de consigna del proceso.
  4. La ruta de aplicación resuelve el valor predeterminado a null, devuelve allPermissions y la escritura tiene éxito.
  5. El atacante llama a un método vinculado en el mismo espacio de nombres, por ejemplo un método de apagado o reinicio. Los métodos no tienen una barrera de nivel de acceso separada, y un método vinculado es ejecutable por defecto.

El lado cliente de la prueba de concepto del aviso es corto:

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: [] });

El aviso también publica una variante NodeSet2 donde setNamespaceMetaData() lanza una excepción, así que ninguna configuración activa el valor predeterminado del espacio de nombres en la versión vulnerable. Esa variante demuestra con más claridad que los permisos por nodo son el único control fiable antes de la actualización.

¿Cómo se desarrolla el ataque?

La política configurada se escribe en un lugar y se lee en otro, así que la ruta de aplicación no ve ninguna política y lo concede todo.

flowchart LR A["El operador declara un valor predeterminado
con setDefaultRolePermissions"] --> B["La política se guarda en
un campo del espacio de nombres"] B --> C["La aplicación lee el nodo
DefaultRolePermissions"] C --> D["El nodo es Null o no existe,
así que la política vale null"] D --> E["getPermissionForRole(null)
devuelve allPermissions"] F["Sesión OPC UA anónima"] --> E E --> G["Se permiten escrituras de Variables
y llamadas a métodos vinculados"] G --> H["Valores de consigna modificados,
métodos ejecutados"] style E fill:#f8d7da

¿Qué puede hacer un atacante?

Un atacante anónimo puede escribir variables y llamar a métodos que el operador creía protegidos por el valor predeterminado del espacio de nombres. Eso incluye valores de consigna del proceso, parámetros de seguridad y métodos que apagan, reinician o reconfiguran un dispositivo. El aviso también concede lectura, así que el atacante puede inspeccionar primero los valores.

Las condiciones forman parte del impacto:

  • El servidor debe ser accesible por red, y debe aceptar una sesión anónima o dar al atacante una sesión con un rol que el valor predeterminado del espacio de nombres no concede.
  • Debe haber un valor predeterminado de espacio de nombres declarado, y el nodo objetivo debe depender de él. Un nodo con RolePermissions explícitos se aplica correctamente y no está afectado.
  • El despliegue determina el impacto en disponibilidad. El aviso puntúa A:N porque un método que afecta la disponibilidad depende del despliegue, y señala que un método de apagado o de administración de roles hace alcanzable A:H.

Un servidor que no declara ninguna política sigue siendo permisivo y queda fuera del alcance de este fallo.

¿Por qué funciona la corrección?

La corrección hace que las dos rutas de resolución coincidan. Cuando el nodo de metadatos no existe o lleva un valor Null, la ruta de aplicación ahora recurre al campo del espacio de nombres que escribe 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();
 }

El mismo commit impide que el servidor sobrescriba un valor predeterminado declarado al arrancar. ServerEngine.initialize() ahora inicializa solo las propiedades sin valor, así que un valor predeterminado declarado por un NodeSet2 sobrevive:

-                    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);
                     }

La corrección está completa para el caso de la política declarada: un valor predeterminado configurado llega ahora a la aplicación, y una política por nodo sigue teniendo prioridad. No cambia el primer caso. Un servidor que no declara ninguna política conserva la resolución permisiva, porque las notas de versión indican que el comportamiento predeterminado de un servidor que no configura ninguno de los dos no cambia. La nueva opción unresolvedPermissionPolicy es el control opcional de denegación por defecto, y su valor predeterminado es "allow".

¿Cómo compruebo si estuve comprometido?

No se publican indicadores de compromiso para este fallo. Una escritura exitosa puede ser invisible en el historial de valores si el atacante restaura el anterior, y muchos servidores OPC UA no guardan un rastro de auditoría por sesión. Lo que puede comprobar:

  1. Busque sesiones anónimas en los registros o eventos de auditoría del servidor, sobre todo alrededor de cambios de consignas, modos o configuración.
  2. Revise los valores y las marcas de tiempo de las variables que dependen de un valor predeterminado de espacio de nombres. Un cambio fuera de una ventana de mantenimiento conocida es una pista.
  3. Compruebe la configuración del servidor para allowAnonymous: true y para un valor predeterminado de espacio de nombres declarado que creía aplicado.
  4. Si su servidor escribe eventos de auditoría OPC UA, busque Anonymous y las operaciones Call o Write sobre nodos sensibles.

Un registro limpio no prueba que no se accediera al servidor. Significa que el servidor no registró el acceso.

Cronología

  • 2026-08-28: la corrección se fusiona como PR #1580, commit 0261e873.
  • 2026-08-30: se publica node-opcua 2.180.0 con la corrección.
  • 2026-10-23: se publica el aviso de GitHub GHSA-xggf-m53x-jc3f.
  • 2026-11-03: el CNA de GitHub publica CVE-2026-95860.

La corrección salió 54 días antes del aviso público, dejando una larga ventana para despliegues sin actualizar
Figura 1: de la corrección al aviso público. Fuente: notas de versión de node-opcua y aviso de GitHub, consultados el 11 de noviembre de 2026

Preguntas frecuentes

¿Afecta a todos los servidores node-opcua?

No. Un servidor que no declara ningún permiso de rol está en el primer caso del aviso, que lo llama comportamiento permisivo documentado, no un bypass. Un servidor que declara un valor predeterminado de espacio de nombres y depende de él para algunos nodos es el caso afectado.

Nunca llamé a setDefaultRolePermissions. ¿Estoy afectado?

Puede estarlo. Un archivo NodeSet2 puede declarar DefaultRolePermissions, y el servidor restablecía ese valor al arrancar en las versiones vulnerables. Compruebe los nodesets cargados y si algún nodo depende del valor predeterminado del espacio de nombres.

¿Se necesita acceso anónimo?

No, pero es la ruta más sencilla. Una sesión autenticada con pocos privilegios también se beneficia cuando su rol no está en el valor predeterminado del espacio de nombres. Desactivar las sesiones anónimas reduce la exposición sin cerrar el fallo.

¿Se explotó en entornos reales?

No se conoce explotación a 11 de noviembre de 2026. El aviso incluye código de prueba de concepto, el bloque SSVC de CISA registra la explotación como poc, y el CVE no está en el catálogo KEV de CISA.

¿2.180.0 hace que mi servidor cierre por defecto?

No. Hace que un valor predeterminado de espacio de nombres declarado se aplique a las sesiones remotas. Un servidor sin política sigue siendo permisivo, salvo que defina unresolvedPermissionPolicy: "deny", que las notas de versión documentan como opción del constructor.

¿Qué paquetes debo actualizar?

Actualice node-opcua a 2.180.0 o posterior. Si su proyecto fija node-opcua-server o node-opcua-address-space por separado, actualícelos a la misma versión para que el tiempo de ejecución y el espacio de nombres coincidan.

Encuentre puntos de conexión OPC UA expuestos con Ostorlab

Utilice Ostorlab Attack Surface Discovery para inventariar los hosts y servicios expuestos a Internet que posee, y así saber qué puntos de conexión OPC UA son accesibles y necesitan la actualización primero. Cartografía los servicios expuestos; no detecta este CVE.

Referencias

  • Aviso de GitHub GHSA-xggf-m53x-jc3f, "node-opcua: Namespace-Level RBAC Fail-Open", publicado el 2026-10-23, con el análisis de la causa raíz y las PoC-A, PoC-B y PoC-C: https://github.com/node-opcua/node-opcua/security/advisories/GHSA-xggf-m53x-jc3f (consultado el 11 de noviembre de 2026)
  • El registro CVE-2026-95860, CNA de GitHub, publicado el 2026-11-03: https://www.cve.org/CVERecord?id=CVE-2026-95860 (consultado el 11 de noviembre de 2026)
  • PR de corrección #1580, "honour namespace default role permissions on the enforcement path", fusionada el 2026-08-28: https://github.com/node-opcua/node-opcua/pull/1580 (consultado el 11 de noviembre de 2026)
  • Commit de corrección 0261e873, 2026-08-28: https://github.com/node-opcua/node-opcua/commit/0261e873900800983c622e01120f484dc6063698 (consultado el 11 de noviembre de 2026)
  • Notas de versión de node-opcua 2.180.0, publicadas el 2026-08-30, con los cambios de comportamiento y la opción unresolvedPermissionPolicy: https://github.com/node-opcua/node-opcua/releases/tag/v2.180.0 (consultado el 11 de noviembre de 2026)
  • La página del paquete node-opcua, 93,781 descargas semanales en la semana que terminó el 2026-11-10: https://www.npmjs.com/package/node-opcua (consultado el 11 de noviembre de 2026)
  • La página del paquete node-opcua-server, 131,431 descargas semanales la misma semana: https://www.npmjs.com/package/node-opcua-server (consultado el 11 de noviembre de 2026)
  • OPC UA parte 3, sección 5.2.9 sobre la herencia de RolePermissions: https://reference.opcfoundation.org/Core/Part3/v105/docs/5.2.9 (consultado el 11 de noviembre de 2026)
  • OPC UA parte 3, sección 4.8.3 sobre la evaluación de permisos con denegación por defecto: https://reference.opcfoundation.org/Core/Part3/v105/docs/4.8.3 (consultado el 11 de noviembre de 2026)
  • El catálogo Known Exploited Vulnerabilities de CISA, versión 2026.11.10, que no incluye CVE-2026-95860: https://www.cisa.gov/known-exploited-vulnerabilities-catalog (consultado el 11 de noviembre de 2026)
  • Figura 1, la cronología corrección-aviso: fechas del commit de corrección, la versión 2.180.0 y el aviso anteriores, consultados el 11 de noviembre de 2026.