Skip to main content

Free 30-min security demo Book Now

Offensive360 Offensive360
ZeroDays CVE-2026-58080
High CVE-2026-58080 CVSS 8.2 Eclipse Milo Java

Eclipse Milo RoleMapper Drop in OPC UA Server

CVE-2026-58080: Eclipse Milo's OpcUaServerConfig.copy() silently drops the RoleMapper, bypassing role-permission checks for anonymous OPC UA clients.

Offensive360 Research Team
Affects: 1.0.0 - 1.1.4
Source Code View Patch

Overview

CVE-2026-58080 is a high-severity authorization bypass vulnerability in Eclipse Milo, the widely deployed open-source Java SDK for the OPC UA protocol stack. The flaw originates in the OpcUaServerConfig.copy() method, which constructs a new server configuration from an existing one but silently omits any configured RoleMapper. Because Milo’s default access controller relies on the RoleMapper to resolve which roles a session holds — and therefore which role-based permissions apply — sessions created against a server built through copy() receive an empty role set. The access controller, seeing no roles to evaluate, skips role-permission enforcement entirely.

The practical result is that any anonymous client — on servers where anonymous sessions are permitted, which is common in industrial and building-automation deployments — can read role-permission metadata, invoke methods gated behind role checks, and delete protected nodes without any credentials. This is not a bypass that requires a crafted payload or a memory corruption primitive; it is a pure logic flaw arising from an incomplete copy constructor pattern, making it reliably exploitable with a standard OPC UA client library.

The vulnerability affects all Eclipse Milo releases from 1.0.0 through 1.1.4. Environments most at risk are OT/ICS systems where Milo-based servers manage process data, manufacturing execution systems, or SCADA gateways that use role-based access control to segregate operator, engineer, and administrator privileges. Security researchers identified the issue and it was assigned through the Eclipse Security team.

Technical Analysis

The root cause lives in the builder pattern used to copy an OpcUaServerConfig instance. The copy() static method initialises a new OpcUaServerConfig.Builder from a live configuration, transferring most fields — endpoint configuration, certificate chain, identity validator, and so on — but omits the call that would propagate the RoleMapper field.

Vulnerable pattern (simplified, pre-patch):

// OpcUaServerConfig.java — affected versions 1.0.0 – 1.1.4
public static OpcUaServerConfig.Builder copy(OpcUaServerConfig config) {
    return new OpcUaServerConfig.Builder()
        .setApplicationName(config.getApplicationName())
        .setApplicationUri(config.getApplicationUri())
        .setEndpoints(config.getEndpoints())
        .setCertificateManager(config.getCertificateManager())
        .setIdentityValidator(config.getIdentityValidator())
        .setCertificateValidator(config.getCertificateValidator())
        .setProductUri(config.getProductUri())
        .setLimits(config.getLimits());
        // ← RoleMapper never forwarded; builder defaults to null / no-op mapper
}

When the OpcUaServer is initialised from the result of copy(), it passes the server configuration to the DefaultAccessController. That controller queries the RoleMapper for the session’s roles at permission-evaluation time:

// DefaultAccessController.java — simplified evaluation path
private Set<NodeId> resolveRoleIds(Session session) {
    RoleMapper mapper = serverConfig.getRoleMapper();
    if (mapper == null) {
        // No mapper configured — return empty set, skip all role checks
        return Collections.emptySet();
    }
    return mapper.getRoleIds(session);
}

public boolean hasPermission(Session session, RolePermissionType[] permissions) {
    Set<NodeId> sessionRoles = resolveRoleIds(session);
    if (sessionRoles.isEmpty()) {
        // BUG: treated as "no role restrictions apply" rather than "no access"
        return true;
    }
    // ... normal intersection check against node role permissions
}

The critical logic error is the fail-open branch: when sessionRoles is empty, hasPermission returns true rather than denying access. This made sense as a convenience default for servers that never configure role permissions, but it becomes catastrophically wrong when a RoleMapper was configured yet was silently dropped by copy(). The server believes it is enforcing roles; the access controller silently waves everyone through.

The attack requires no special tooling. A standard OPC UA client (open62541, the Python opcua library, or Milo itself) connecting anonymously can enumerate protected namespace nodes, call Call service requests on methods restricted to engineer-role, and issue DeleteNodes requests on nodes marked with role permissions — all without receiving a BadUserAccessDenied status code.

Impact

The CVSS 8.2 score (AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:L) reflects the network-reachable, zero-credential nature of exploitation. Concretely, an attacker on the network can:

  • Read role-permission metadata — enumerate which nodes carry role restrictions, identifying high-value targets in the address space.
  • Invoke protected methods — OPC UA method nodes often wrap control commands (start/stop a process, acknowledge alarms, push firmware). Role-gated methods become fully accessible.
  • Delete protected nodesDeleteNodes on role-restricted objects can disrupt running processes or permanently corrupt the server’s address space.
  • Tamper with variable valuesWrite service calls to nodes whose RolePermissions attribute restricts write access will succeed.

In ICS and building-automation contexts, the blast radius extends beyond data disclosure to potential physical-process interference. Any Milo-based OPC UA server that (a) builds its running configuration through copy(), (b) configures a RoleMapper on the source config, and (c) permits anonymous sessions is fully exposed.

How to Fix It

Upgrade immediately. The Eclipse Milo project patched this in the commit referenced below by adding the missing setRoleMapper propagation to copy():

// OpcUaServerConfig.java — patched
public static OpcUaServerConfig.Builder copy(OpcUaServerConfig config) {
    return new OpcUaServerConfig.Builder()
        .setApplicationName(config.getApplicationName())
        .setApplicationUri(config.getApplicationUri())
        .setEndpoints(config.getEndpoints())
        .setCertificateManager(config.getCertificateManager())
        .setIdentityValidator(config.getIdentityValidator())
        .setCertificateValidator(config.getCertificateValidator())
        .setProductUri(config.getProductUri())
        .setLimits(config.getLimits())
        .setRoleMapper(config.getRoleMapper()); // ← fix: propagate RoleMapper
}

Additionally, the fail-open branch in DefaultAccessController should be hardened so that an absent mapper denies rather than permits role-gated operations. If you maintain a fork or a custom access controller, audit every branch that evaluates an empty role set.

Package manager upgrade commands:

<!-- Maven — pom.xml -->
<dependency>
    <groupId>org.eclipse.milo</groupId>
    <artifactId>sdk-server</artifactId>
    <version>1.1.5</version> <!-- or latest post-patch release -->
</dependency>
// Gradle — build.gradle
implementation 'org.eclipse.milo:sdk-server:1.1.5'

Short-term mitigation (if you cannot upgrade immediately): disable anonymous sessions on the server endpoint and enforce certificate-based or username/password authentication. This does not fix the underlying flaw but removes the unauthenticated attack path.

Our Take

This vulnerability is a textbook example of a fail-open security default hiding behind an incomplete builder pattern. The copy() idiom is pervasive in Java configuration APIs, and it is easy for a developer to add a new security-relevant field to a class without auditing every copy(), clone(), or Builder.from() path that constructs that class. The result is a configuration drift between what the developer intended and what the runtime enforces — a gap that is invisible in logs and unit tests unless someone explicitly tests role enforcement on a server constructed through copy().

For enterprises, this class of bug is particularly dangerous in OT/ICS stacks because OPC UA servers often run with long uptime and minimal monitoring, the network segments they live on are sometimes trusted by default, and the consequences of unauthorized method invocation can be physical rather than just informational.

The broader lesson: security-sensitive fields in mutable configuration objects must be treated with the same rigor as cryptographic keys. Any path that constructs or copies a configuration object is a potential place for a field to go missing, and that missing field may silently degrade the security posture of the entire server.

Detection with SAST

This vulnerability class maps to CWE-284 (Improper Access Control) and more specifically to CWE-1173 (Improper Use of Validation Framework) and CWE-453 (Insecure Default Variable Initialization). A SAST rule targeting this pattern should flag:

  • Incomplete builder/copy methods: data-flow analysis that tracks all fields set on a security-configuration object at construction time and reports any field whose type is a security interface (RoleMapper, AccessController, IdentityValidator, etc.) that is not propagated through every copy() or Builder.from() path.
  • Fail-open access-control branches: taint-tracking from getRoleMapper() / getIdentityValidator() return values into conditional branches where a null or empty result causes a permission check to return true.
  • Missing null-check guard on security callbacks: any access-control predicate that short-circuits to permit when its underlying resolver returns null or an empty collection.

Offensive360’s SAST engine flags copy-constructor completeness gaps by constructing a field-coverage graph for builder types annotated with or containing security-interface fields, then diffing the fields written in each static factory method against the complete field list. The fail-open branch pattern is caught by a taint rule that traces nullable security-resolver return values into boolean permission predicates.

References

#OPC UA #Authorization Bypass #Configuration Bug #ICS/SCADA

Detect this vulnerability class in your codebase

Offensive360 SAST scans your source code for CVE-2026-58080-class vulnerabilities and thousands of other patterns — across 60+ languages.