[Eclipse Arrowhead] `CertificateMqttFilter` accepts unverified certificates from the MQTT message body — full sysop authentication bypass
> [!warning] AI-Generated Vulnerability Report > This report was produced using AI-assisted security analysis, adversarially verified but not human-confirmed. This finding was challenged against the codebase and survived; we filter out low-confidence results rather than forwarding raw model output. Technical details, exploitability, and severity may still be wrong. Please validate independently before acting. <details open> <summary><strong>Basic information</strong></summary> **Project name:** Eclipse Arrowhead **Project id:** iot.arrowhead **Repository:** https://github.com/eclipse-arrowhead/ah5-common-java-spring </details> <details> <summary><strong>What are the affected versions?</strong></summary> v5.0.0, v5.1.0, v5.2.0 (all released versions; the file exists with the same logic in every tag). </details> <details open> <summary><strong>Summary</strong></summary> When the MQTT API is enabled with the (default) `certificate` authentication policy, `CertificateMqttFilter` parses an X.509 certificate that the client sends **inside the MQTT message payload** (the `authentication` field of `MqttRequestTemplate`) and treats its Subject DN as the authenticated identity. The certificate is decoded with `CertificateFactory.generateCertificate()` but **its signature is never verified and its issuer chain is never validated against any trust store**. Authorisation is reduced to two string comparisons on attacker-supplied data: the DN-qualifier must equal `"sy"` or `"op"`, and the cloud-name part of the CN must match the server's. Both values are public (the cloud name is in the server's own TLS certificate). An attacker who can publish to the MQTT broker can therefore mint a self-signed certificate with `CN=Sysop.<cloud>.<org>.arrowhead.eu, dnQualifier=op`, send it as the `authentication` field, and be authenticated as the cloud's system operator with `isSysOp == true`. This passes the downstream `ManagementServiceMqttFilter` (`request.isSysOp()` → allowed) and gives full management access over MQTT. The HTTP `CertificateFilter` is **not** affected — it reads the certificate from `jakarta.servlet.request.X509Certificate`, which Tomcat populates only after a successful mTLS handshake against the configured trust store. </details> <details> <summary><strong>Severity</strong></summary> **High** | Factor | Assessment | |---|---| | Preconditions | `mqtt.api.enabled=true` (default `false`); `authentication.policy=certificate` (the default policy); ability to publish to the broker. | | Impact | Complete authentication bypass → arbitrary identity spoofing including operator/sysop → full management API access (register/delete systems, services, devices, authorisations). | | Why not Critical | The MQTT API is opt-in; on a fresh quickstart with no configuration it is disabled. Once enabled, no further misconfiguration is required. | The MQTT API is a first-class, documented interface of every Arrowhead 5 core system that this library underpins; enabling it with the *default* authentication policy is a normal, supported deployment step, and that single step is sufficient for the bypass. A secondary mitigating control could be broker-level mTLS that restricts who may publish at all, but `CertificateMqttFilter` is *the* component responsible for binding an MQTT request to an Arrowhead identity; nothing in this library ties the application-layer certificate to the TLS session, so even a legitimately-connected low-privilege system can impersonate the operator. </details> <details> <summary><strong>Weakness</strong></summary> CWE-295 (Improper Certificate Validation) → CWE-287 (Improper Authentication) / CWE-290 (Authentication Bypass by Spoofing). </details> <details> <summary><strong>Location</strong></summary> - `common-utils/src/main/java/eu/arrowhead/common/mqtt/filter/authentication/CertificateMqttFilter.java` - `decodeAuthorizationKey` — lines 92‑115: `CertificateFactory.generateCertificate()` only, no `verify()` / `CertPathValidator`. - `checkClientAuthorized` — lines 118‑132: profile-type and cloud-name string comparison only. - `fillRequestAttributes` — lines 135‑142: `request.setSysOp(CertificateProfileType.OPERATOR == requesterData.profileType())`. - `common-utils/src/main/java/eu/arrowhead/common/security/SecurityUtilities.java` - `getIdentificationDataFromCertificate` — lines 156‑165: extracts CN/DN-qualifier from Subject DN; no cryptographic check. - `isClientInTheLocalCloudByCNs` — lines 196‑219: pure string equality of normalised cloud identifiers. - `common-utils/src/main/java/eu/arrowhead/common/mqtt/handler/MqttHandlerUtils.java` - `parseMqttMessage` — lines ~76‑80: deserialises the `authentication` field directly from the MQTT payload and passes it to the filter chain as `authKey`. - `data-transfer-objects/src/main/java/eu/arrowhead/dto/MqttRequestTemplate.java` — line 24: `String authentication` is part of the wire payload. A `grep` for `verify(`, `CertPathValidator`, `PKIXParameters`, `TrustManager` across `common-utils/src/main` returned no certificate-validation calls on this path. </details> <details> <summary><strong>Origin</strong></summary> Introduced in commit `e621c5d` ("CertificateMqttFilter implementation"), refined in `8851cbb`, merged via PR \#20 (`tb/mqtt-cert`, 2025-01-16). The flaw has been present, unchanged in substance, since the file was created. </details> <details> <summary><strong>Methodology</strong></summary> 1. Mapped data flow: `MqttSubscriptionHandler.messageArrived` → `MqttDispatcher` → `MqttTopicHandler` → `MqttMessageContainerHandler.run` → `MqttHandlerUtils.parseMqttMessage` (Jackson `readValue` of payload into `MqttRequestTemplate`) → `template.authentication()` → filter `doFilter(authKey, request)`. Confirmed `authKey` is fully attacker-controlled body content. 2. Audited `CertificateMqttFilter` for any link between the parsed certificate and (a) the TLS session client cert or (b) a trust anchor: none found. 3. Audited `SecurityUtilities` for signature/chain validation entry points: none invoked. 4. Compared with the HTTP `CertificateFilter`, which obtains its certificate from the servlet container's verified mTLS attribute — confirming the MQTT filter's design diverges from the secure pattern used elsewhere in the same codebase. 5. Wrote and ran an end-to-end reproduction (no `SecurityUtilities` mocking) using a freshly self-signed certificate. 6. Searched git history (`git log --all --grep`), GitHub issues and PRs for prior reports or fixes: none. </details> <details> <summary><strong>Validation</strong></summary> Reproduction added at `common-utils/src/test/java/eu/arrowhead/common/mqtt/filter/authentication/CertificateMqttFilterForgedCertTest.java`. It uses **the real production code path** (no static mocking of `SecurityUtilities`, real `CloudIdentifierNormalizer`/`SystemNameNormalizer`) and a self-signed certificate generated with an attacker key unrelated to any Arrowhead PKI. Certificate generation: ```sh openssl req -x509 -newkey rsa:2048 -nodes -keyout forged.key -out forged.pem -days 365 \ -subj '/dnQualifier=op/CN=Sysop.TestCloud.Company.arrowhead.eu' base64 -w0 forged.pem # -> value for the "authentication" JSON field ``` Run: ```sh mvn -q -pl common-utils -Dcheckstyle.skip -Dtest=CertificateMqttFilterForgedCertTest test ``` Observed output: ``` requester = Sysop isSysOp = true ``` The test (`forgedSelfSignedCertGrantsSysop`) constructs the filter with `arrowheadContext = { server.common.name = "ServiceRegistry.TestCloud.Company.arrowhead.eu" }`, calls `filter.doFilter(FORGED_AUTH_KEY, request)`, and asserts `request.isSysOp()` and `request.getRequester().equals("Sysop")`. Both assertions pass. </details> <details> <summary><strong>Detail</strong></summary> `decodeAuthorizationKey` (lines 99‑113): ```java String decodedX509PEM = new String(Base64.getDecoder().decode(authKey)); decodedX509PEM = decodedX509PEM.replace(beginCert, "").replace(endCert, "").replaceAll(whitespaceRegexp, ""); final byte[] decodedX509RawContent = Base64.getDecoder().decode(decodedX509PEM); final CertificateFactory certificateFactory = CertificateFactory.getInstance(Constants.X_509); final ByteArrayInputStream certStream = new ByteArrayInputStream(decodedX509RawContent); return (X509Certificate) certificateFactory.generateCertificate(certStream); ``` `generateCertificate()` parses DER; it does not verify the signature, the issuer, the validity period, key usage, or revocation. The returned object is then passed to `SecurityUtilities.getIdentificationDataFromCertificate`, which calls `certificate.getSubjectX500Principal().getName(RFC2253)` and parses out `CN` and OID `2.5.4.46` (DN qualifier). The DN-qualifier bytes are decoded as UTF-8 and `.trim()`ed — for a `PrintableString "op"` (DER `13 02 6f 70`) the leading control bytes ≤ 0x20 are stripped, yielding `"op"` → `CertificateProfileType.OPERATOR`. `checkClientAuthorized` then: 1. Requires `profileType` ∈ {`SYSTEM`, `OPERATOR`} — attacker chooses `op`. 2. Compares the cloud part of the requester CN against the cloud part of `arrowheadContext[server.common.name]` via `isClientInTheLocalCloudByCNs`, which normalises both to `<cloud>|<org>` strings and tests `String.equals`. The server's CN (and hence cloud name) is exposed in its TLS certificate and via the `serviceDiscovery`/`monitor` endpoints, so the attacker copies it. `fillRequestAttributes` finally sets `request.setRequester("Sysop")` and `request.setSysOp(true)`. `ManagementServiceMqttFilter.doFilter` (line 79) short-circuits on `request.isSysOp()`, so all management operations are permitted. The contrast with HTTP is instructive: `SecurityUtilities.getIdentificationDataFromRequest` (lines 137‑152) reads `jakarta.servlet.request.X509Certificate`, which Tomcat sets **only after** a successful TLS client-certificate handshake validated against `server.ssl.trust-store`. The MQTT filter was apparently written to mirror that API but substituted a body field for the TLS attribute, losing the cryptographic binding entirely. </details> <details> <summary><strong>Phase 2 — remaining sub-phases</strong></summary> **2.2 Mathematical/bounds proof — N/A.** This is an authentication logic bug, not a memory/integer bounds issue. There is no arithmetic condition to bound. The "condition possible" proof is Boolean instead: authorization succeeds iff `dnQualifier ∈ {sy, op}` AND `cloudId(clientCN) == cloudId(serverCN)`. Both operands are attacker-supplied (`dnQualifier`) or public and copyable (`serverCN`'s cloud portion), so the predicate is trivially satisfiable — proven concretely by the executable PoC returning `true` on both checks. **2.3 Race condition feasibility — N/A.** The trigger is a single synchronous filter-chain pass over one message (`MqttMessageContainerHandler.run`, `:70-79`); no TOCTOU or concurrent state is involved. The bypass is deterministic on a single request. **2.4 Adversarial analysis.** Full attacker surface: attacker controls the entire `authentication` field (arbitrary DER cert) and the `payload`/topic. The only value they cannot invent — the local cloud CN — is disclosed in the server's TLS certificate and monitor/echo responses. No validation bypass is even required because no validation exists; the attacker uses the intended, well-formed input shape. </details> <details> <summary><strong>Phase 3 — Impact Assessment (traced independently)</strong></summary> **3.1 Real security impact.** This is not operational robustness — it is a complete authentication bypass yielding a spoofed *operator* identity. `request.setSysOp(true)` (`CertificateMqttFilter.java:141`) grants, via `ManagementServiceMqttFilter` (`:77-88`), the full management surface of whichever core system enabled MQTT: register/delete systems, services, devices, and (in ConsumerAuthorization) manipulate authorization rules. Choosing `dnQualifier=sy` with an arbitrary system CN instead lets the attacker impersonate any *specific* application system. This is privilege escalation + authentication bypass (RCE-equivalent in the authorization domain of the framework). **3.2 Primary vs defense-in-depth.** `CertificateMqttFilter` *is* the primary authentication control for the MQTT API — not a secondary hardening layer. Its failure is a primary-control failure. Broker-level mTLS, if present, is a separate component's control and does not bind the app-layer identity, so it cannot be counted as the primary protection here. </details> <details> <summary><strong>Phase 4 — PoC Creation (completed)</strong></summary> **4.1 Pseudocode + data-flow diagram.** ``` ATTACKER BROKER ARROWHEAD CORE SYSTEM (mqtt.api.enabled=true, policy=CERTIFICATE) | | | | openssl x509 self-signed: | | | dnQualifier=op | | | CN=Sysop.<cloud>.<org>.arrowhead.eu | | base64(PEM) = AUTHKEY | | | | | | publish arrowhead/<sys>/management/... { | | "authentication": AUTHKEY, "responseTopic":"a/r", ...}| |-------------------------------->|----------------------->| messageArrived (MqttController:137) v MqttDispatcher.queueMessage (:97) -- raw bytes, no inspection v MqttHandlerUtils.parseMqttMessage (:77-80) authKey = template.authentication() <-- ATTACKER DATA v CertificateMqttFilter.doFilter (:74) decodeAuthorizationKey: generateCertificate() [NO verify / NO trust store] getIdentificationDataFromCertificate: DN -> (CN, OPERATOR) checkClientAuthorized: "op" ok; cloud string-match ok fillRequestAttributes: setSysOp(true) <-- FORGED IDENTITY v ManagementServiceMqttFilter.doFilter (:78) allowed = request.isSysOp() == true v topicHandler.handle(request) -> management op executes as SYSOP |<----------------------------------------------------------| success on "a/r" ``` **4.2 Executable PoC.** Completed earlier: forged self-signed cert (attacker key, no PKI) run through the filter's exact parse/authorize logic → `isSysOp=true`, `requester=Sysop`. Confirmed. **4.3 Unit test PoC.** The report ships one at `common-utils/src/test/java/.../CertificateMqttFilterForgedCertTest.java` (`forgedSelfSignedCertGrantsSysop`) using the unmocked production `SecurityUtilities`/normalizers and asserting `request.isSysOp()` and `request.getRequester().equals("Sysop")`. My standalone executable PoC independently corroborates it, so I did not need to re-run their JUnit harness (which requires the full Maven module build); the logic was reproduced verbatim instead. **4.4 Negative PoC — preconditions.** The bypass fails, as expected, when the attacker's crafted DN deviates: - `dnQualifier` ∉ {`sy`,`op`} (e.g. `de`/`ma`) → `checkClientAuthorized` throws `ForbiddenException` at `:121-123` (`profileType` gate). Confirms the profile field is load-bearing and attacker sets it deliberately. - Cloud portion of CN ≠ server's cloud CN → `isClientInTheLocalCloudByCNs` returns `false` → `ForbiddenException` at `:128-130`. Confirms the only "secret" is the public cloud name. - CN not exactly 5 dot fields → `isValidSystemCommonName` false → `getIdentificationDataFromCertificate` returns null → `AuthException` at `:79-82`. These show the gap between normal operation (a *trusted* cert issued by the cloud CA passes) and the exploit (an *untrusted* self-signed cert passes identically) — the missing signature/chain check is the entire difference; nothing else distinguishes the two. **4.5 PoC verification.** The executable PoC exercises the real code path's logic with a crafted input and demonstrates attacker control (arbitrary cert), trigger (parse + string checks), and impact (`isSysOp=true`). Verified consistent with the report's production-class unit test. </details> <details> <summary><strong>Phase 5 — Devil's Advocate (all 13)</strong></summary> 1. *Non-vuln explanation for the pattern?* None — an authentication filter that trusts an unverified body certificate has no benign reading. 2. *How would devs justify it?* Likely mirrored the HTTP `CertificateFilter` API shape but substituted a body field for the TLS-verified servlet attribute, losing the crypto binding — a mistake, not a design intent. 3. *Missing architecture context?* Considered broker mTLS; it doesn't bind app-layer identity, so it doesn't rescue the control. 4. *"Looks dangerous" vs is dangerous?* Proven dangerous by executable PoC, not pattern-matching. 5. *Does weak validation still block the condition?* No — the string checks are fully satisfiable by attacker/public data. 6. *Assuming attacker control over trusted data?* No — traced the field from the raw MQTT payload; it is untrusted body content (`MqttHandlerUtils.java:79`). 7. *Condition rigorously proven possible?* Yes — Boolean predicate satisfied, PoC returns authorized. 8. *Practically exploitable?* Yes — one `openssl` command + one MQTT publish; no timing/heap/race hurdles. 9. *Defense-in-depth confusion?* No — this is the primary auth control (Phase 3.2). 10. *Compiler/runtime/OS protections?* None apply to a logic bug in managed Java. 11. *Hallucinating the bug?* No — absence of verification confirmed by direct read and independent trace; PoC empirically reproduces the bypass. 12. *Dismissing a real bug as too complex?* Not dismissed — it is in fact simple and high-impact; no under-rating. 13. *Inventing mitigations not in source?* Re-read the path after concluding; there is no `verify`/`checkValidity`/`CertPathValidator`/`TrustManager`/trust-store call anywhere on it. No mitigation was invented. </details> <details> <summary><strong>Gate Review (unchanged)</strong></summary> All six gates PASS (Process, Reachability, Real Impact, PoC Validation, Math/Logic Bounds, Environment), as tabulated in the prior message. </details> <details> <summary><strong>Final verdict</strong></summary> **BUG \#1 TRUE POSITIVE** — `CertificateMqttFilter` authenticates MQTT requests using an X.509 certificate taken from the message body with no signature or chain verification against any trust store (`CertificateMqttFilter.java:99-142`, `SecurityUtilities.java:156-165`), so an attacker who can publish to the broker forges a self-signed `dnQualifier=op` certificate and is authenticated as the cloud system operator (`isSysOp=true`), obtaining full MQTT management access (`ManagementServiceMqttFilter.java:78-88`). Default policy (`certificate`) and single opt-in (`mqtt.api.enabled=true`) are the only preconditions. - **1 TRUE POSITIVE, 0 FALSE POSITIVE.** - **EXPOSURE: REMOTE** (broker-publish reach; no in-library credential gate before the sink). - **SCOPE: PRODUCTION** (shipped Arrowhead 5 core-system library, live Eclipse disclosure policy). - Project is actively maintained — a maintainer code fix (PKIX validation against the existing trust store, plus proof-of-possession to close the certificate-replay hole) is warranted. </details> <details> <summary><strong>Steps to reproduce</strong></summary> Preconditions: target Arrowhead core system has `mqtt.api.enabled=true` and the default `authentication.policy=certificate`; attacker can reach the MQTT broker and publish to a service topic (e.g. `arrowhead/serviceregistry/management/`). Knowledge of the broker credentials, if any, suffices for any client of the local cloud — i.e. this is at minimum a privilege escalation from *any* MQTT participant to *operator*, and where the broker is open it is unauthenticated remote takeover. 1. Connect to the broker (TLS handshake to the broker — not to the Arrowhead system — is orthogonal). 2. Observe the target cloud name from the server's TLS certificate or from a `monitor`/`echo` response. 3. `openssl req -x509 … -subj '/dnQualifier=op/CN=Sysop.<cloud>.<org>.arrowhead.eu'` and `base64` the PEM. 4. Publish to any management topic, e.g.: **Proof of concept:** [poc.json](/uploads/fe627015b9b87f913288570ac116e1d8/poc.json) 5. Receive a successful response on `attacker/resp`. The attacker can now register rogue systems/services, delete legitimate ones, manipulate authorisation rules in ConsumerAuthorization, etc. By choosing `dnQualifier=sy` and an arbitrary system CN instead, the attacker can impersonate any *specific* application system rather than the operator. </details> <details> <summary><strong>Do you know any mitigations of the issue?</strong></summary> The certificate carried in the message body must be cryptographically bound to a trust anchor before any field of its Subject DN is used for authorisation. Concretely, `CertificateMqttFilter` should: 1. Build a `CertPath` from the supplied certificate (and any intermediates) and validate it with `CertPathValidator("PKIX")` against the same trust store the HTTP connector uses (`SSLProperties` already exposes it). Reject if validation fails, the leaf is expired, or the EKU does not include client-auth. 2. Verify possession of the private key — otherwise an attacker can replay any *legitimate* client certificate they have observed (certificates are public). The cleanest design is to stop accepting an in-band certificate at all and instead require the broker to be configured for mTLS and surface the verified client identity to the handler (Paho does not do this natively, so this typically means trusting the broker's authenticated client-id, or signing each message with the client's private key and verifying that signature here). A minimal patch that closes the *forgery* hole (but **not** the replay hole — see above) is: **Suggested fix:** [fix.java](/uploads/af51e8c2d18b465a04e69616686d2320/fix.java) The maintainers should be advised that even with PKIX validation, **bearer** certificates are replayable; a proper fix needs proof-of-possession. --- **Verification verdict:** TRUE POSITIVE <!-- l1-helper-dup: 28c2bda2f189caf221bd3cbbc13586bf833d19d0160000291851016d407fd665 --> </details>
issue

Copyright © Eclipse Foundation AISBL. All rights reserved.     Privacy Policy | Terms of Use | Copyright Agent