[Eclipse Milo] 6 security findings in Eclipse Milo
<!--Note that this issue is configured (see the quick actions at the bottom) to be created as confidential.
Note that a vulnerability does not need to actually be resolved before it is reported and that these reports can be revised as needed (reopen the issue to request changes).
If you do not know how to fill certain fields, mark that in the comment and we will help you.
You can delete the comments (or not).-->
<!--Required. Specify the project's name (e.g., "Eclipse Dash") and Eclipse Foundation ID, e.g., "technology.dash".-->
## Basic information
- **Project name:** Eclipse Milo
- **Project id:** `iot.milo`
## What are the affected versions?
All 1.x releases, v1.0.0 through v1.1.4 (the latest), and current `main` at commit `56cabec5`. I checked each issue in the source at both v1.0.0 and v1.1.4, so every shipped release is affected. The code links below point at `main`, but the same code is in the release tags.
## Details of the issue
I found six issues plus three smaller hardening gaps. Scores are CVSS 3.1.
| \# | Finding | Impact |
|----|---------|--------|
| 1 | Call mixed-batch authorization bypass | A denied method runs when batched with an allowed one |
| 2 | Pre-session direct-memory leak | Unauthenticated leak of pooled buffers; a memory-limited server was OOM-killed |
| 3 | `OpcUaServerConfig.copy()` drops `RoleMapper` | Role checks fail open on a copied config |
| 4 | Cross-session diagnostics disclosure | An anonymous client reads other sessions' usernames and certificates |
| 5 | Recursive PubSub decode and counter leak | A request causes `StackOverflowError`; a finite monitored-item quota can be exhausted |
| 6 | RSA padding oracle in username-token processing | A captured password was recovered and used to log in |
**Finding 1.** In the Call service, `DefaultMethodServiceSet` works out which methods are allowed and which are denied, but then runs the original `methodsToCall` list instead of the allowed `group` ([DefaultMethodServiceSet.java#L98](https://github.com/eclipse-milo/milo/blob/56cabec56fffc171479209c043903b8ef0abf1c4/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/servicesets/impl/DefaultMethodServiceSet.java#L98)). The Write service does it right for comparison ([DefaultAttributeServiceSet.java#L274](https://github.com/eclipse-milo/milo/blob/56cabec56fffc171479209c043903b8ef0abf1c4/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/servicesets/impl/DefaultAttributeServiceSet.java#L274)). So if one request holds an allowed method and a denied method, both run. A later result-count error stops the response, but the denied method has already done its work.
**Finding 2.** The UASC transport keeps partial message chunks but does not free them on every disconnect. The asymmetric handler's [`channelInactive`](https://github.com/eclipse-milo/milo/blob/56cabec56fffc171479209c043903b8ef0abf1c4/opc-ua-stack/transport/src/main/java/org/eclipse/milo/opcua/stack/transport/server/uasc/UascServerAsymmetricHandler.java#L130-L136) frees nothing (only `exceptionCaught` does), and the symmetric handler has no cleanup at all after [holding a chunk](https://github.com/eclipse-milo/milo/blob/56cabec56fffc171479209c043903b8ef0abf1c4/opc-ua-stack/transport/src/main/java/org/eclipse/milo/opcua/stack/transport/server/uasc/UascServerSymmetricHandler.java#L164). Because `SecurityPolicy.None` is [accepted](https://github.com/eclipse-milo/milo/blob/56cabec56fffc171479209c043903b8ef0abf1c4/opc-ua-stack/transport/src/main/java/org/eclipse/milo/opcua/stack/transport/server/uasc/UascServerAsymmetricHandler.java#L319-L326) for the channel setup, no login is needed. An attacker sends partial chunks and disconnects, and the pooled off-heap memory is never returned. Garbage collection does not help. On a memory-limited server this ends in an out-of-memory kill.
**Finding 3.** [`OpcUaServerConfig.copy()`](https://github.com/eclipse-milo/milo/blob/56cabec56fffc171479209c043903b8ef0abf1c4/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/OpcUaServerConfig.java#L155-L172) copies most fields but forgets the `RoleMapper`. With no mapper, [`Session.getRoleIds()`](https://github.com/eclipse-milo/milo/blob/56cabec56fffc171479209c043903b8ef0abf1c4/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/Session.java#L181-L187) is empty and `DefaultAccessController` [skips its role-permission checks](https://github.com/eclipse-milo/milo/blob/56cabec56fffc171479209c043903b8ef0abf1c4/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/servicesets/impl/DefaultAccessController.java#L310-L312) instead of denying. Any server that uses role permissions and builds its config through `copy()` loses those checks. In testing this let an anonymous client run a protected method, read role-permission data, and delete a protected node.
**Finding 4.** The server diagnostics `EnabledFlag` is [readable and writable by anyone](https://github.com/eclipse-milo/milo/blob/56cabec56fffc171479209c043903b8ef0abf1c4/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/namespaces/ServerNamespace.java#L113-L115), with no role permissions on it. If the server allows anonymous sessions, an anonymous client can turn diagnostics on and read [`SessionSecurityDiagnostics`](https://github.com/eclipse-milo/milo/blob/56cabec56fffc171479209c043903b8ef0abf1c4/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/diagnostics/SessionSecurityDiagnostics.java#L43-L101) for every session. That exposes other users' usernames, login history, and public certificates. It does not expose passwords or private keys.
**Finding 5.** The generated PubSub decoders have no depth limit across nested values, so a message that nests a `DataSetWriterDataType` inside itself (about 700 KiB) causes a `StackOverflowError`. These decoders are always registered, even if the server never uses PubSub, and an anonymous client can reach them through a monitored-item filter. The server itself stays up and returns an error, but the monitored-item counter is [raised before the item is created](https://github.com/eclipse-milo/milo/blob/56cabec56fffc171479209c043903b8ef0abf1c4/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/subscriptions/SubscriptionManager.java#L417-L418) and the error skips the cleanup, so one count leaks per request. If `maxMonitoredItems` is set to a finite value, repeating this eventually blocks all clients from creating monitored items until restart. (The default limit is unlimited, so this only bites a server with a configured limit.)
**Finding 6.** When it decrypts a username token, the server returns different error codes for different failures: a bad RSA padding, a bad decrypted structure, and a wrong password ([decrypt](https://github.com/eclipse-milo/milo/blob/56cabec56fffc171479209c043903b8ef0abf1c4/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/identity/AbstractIdentityValidator.java#L177-L215), [validate](https://github.com/eclipse-milo/milo/blob/56cabec56fffc171479209c043903b8ef0abf1c4/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/identity/AbstractUsernameIdentityValidator.java#L73-L133)). The "padding good vs bad" difference is exactly the signal a Bleichenbacher attack needs. This applies when the server offers a `Basic128Rsa15` username policy (supported, but not the default) and the attacker has captured one encrypted username token. An anonymous session is enough to ask the server the same question many times, and there is no rate limit on repeated `ActivateSession` calls.
**Hardening notes (smaller issues).**
- If an X.509 token policy resolves to `None`, the server skips the signature check and trusts the certificate as-is, so a public certificate becomes a bearer login ([validator](https://github.com/eclipse-milo/milo/blob/56cabec56fffc171479209c043903b8ef0abf1c4/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/identity/AbstractX509IdentityValidator.java#L74-L88)). This matches the spec, but it is worth a warning or a rejection.
- `DefaultAccessController` checks the `User*` access attributes but not the base ones, so a misconfigured node with permissive user attributes can be read or called past its base setting ([here](https://github.com/eclipse-milo/milo/blob/56cabec56fffc171479209c043903b8ef0abf1c4/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/servicesets/impl/DefaultAccessController.java#L599-L612)).
- A malformed identity token is swallowed and replaced with an anonymous one, so on an anonymous-friendly endpoint bad input logs in as anonymous instead of being rejected ([SessionManager.java#L915-L938](https://github.com/eclipse-milo/milo/blob/56cabec56fffc171479209c043903b8ef0abf1c4/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/SessionManager.java#L915-L938)).
## Steps to reproduce
I have a runnable test for each finding (available on request) that starts a test server, runs the attack, and shows the vulnerable-vs-fixed difference. To confirm by hand:
**Finding 1.** Set up a `RoleMapper` with a method the test user may call and another the test user is denied. Send one `CallRequest` with both. The denied method runs (its side effect happens); the client gets no normal response.
**Finding 2.** Start any server. From an unauthenticated TCP client, open a channel (`None` is accepted for setup) and send message chunks where the final-chunk flag is never set, then disconnect. Repeat. Watch pooled direct memory climb and never come back, even after a forced GC. With a container memory limit, the server is OOM-killed.
**Finding 3.** Build a config with a `RoleMapper`, then create the running config with `OpcUaServerConfig.copy(config)`. As an anonymous client, do something role-protected (call a protected method, read a `RolePermissions` attribute, or delete a protected node). It works on the copied server but is denied on one built straight from the builder.
**Finding 4.** Start a server that allows anonymous sessions. As anonymous, over an encrypted endpoint, set the diagnostics `EnabledFlag` to true, then read the session diagnostics array. You get other sessions' usernames and certificates.
**Finding 5.** As anonymous, call `CreateMonitoredItems` with an event filter that nests a `DataSetWriterDataType` about 20,000 levels deep (\~700 KiB). The server logs `StackOverflowError` and returns `Bad_UnexpectedError`, and the counter is not put back. On a server with a finite `maxMonitoredItems`, repeat until every client gets `Bad_TooManyMonitoredItems`.
**Finding 6.** Configure a `Basic128Rsa15` username policy. Capture one victim's encrypted username token (256 bytes for a 2048-bit key). Open an anonymous session and send many `ActivateSession` requests carrying modified versions of that ciphertext, reading the result each time (`Bad_SecurityChecksFailed` means bad padding, anything else means good). Run Bleichenbacher's method over those answers to recover the password. In my test against a 2048-bit key this took 54,126 requests (about 26 seconds); logging in with the recovered password then succeeded.
## Do you know any mitigations of the issue?
Each finding needs a code fix, but there are interim steps:
- **Finding 1.** No config workaround; the denied method still runs. Until fixed, avoid exposing role-restricted methods on the same server as methods lower-privileged users may call. Fix: pass the allowed `group`.
- **Finding 2.** Set `-XX:MaxDirectMemorySize` so the leak causes allocation failures and slowness instead of an outright kill; add connection limits and idle timeouts; avoid exposing `None`. Fix: free the buffered chunks on disconnect in both handlers.
- **Finding 3.** Don't build the running config through `copy()`; use the builder directly and set the `RoleMapper` yourself. Check at startup that `getRoleMapper()` is present. Fix: copy the mapper.
- **Finding 4.** Turn off diagnostics, or don't allow anonymous sessions, or add role permissions on the diagnostic nodes. Fix: restrict diagnostics to an admin role.
- **Finding 5.** Leave `maxMonitoredItems` at its default (unlimited) to avoid the lasting effect; limit anonymous monitored-item creation. Fix: add a depth limit across decoders and only count an item once it is created.
- **Finding 6.** Don't offer a `Basic128Rsa15` username policy (use the default `Basic256Sha256`); use encrypted channels so the token can't be captured. Fix: return one generic error for all username-token failures and rate-limit the login path.
---
I follow coordinated disclosure and suggest a public date of 2026-08-25 (happy to adjust). Full per-finding reports and runnable PoCs are available on request.
<!--Please, do not remove the line below. It will create a confidential issue that will be visible
only to you and the members of this project. Confidential issues are used to keep security
vulnerabilities private until they are sorted out.
Eclipse Projects follow Responsible Disclosure best practices: the initial report is made privately,
but with the full details being published once a patch has been made available (sometimes with
a delay to allow more time for the patches to be installed).-->
issue