0x07 · CVE field note
CVE-2026-40976: A misplaced brace, the entire Spring Boot 4.0 auth chain, and why the public framing is wrong on three counts
spring-boot-starter-parent:4.0.6 shipped on 2026-04-23 with a one-character logical fix: a closing brace moved up four lines. Behind that brace sits an authorization bypass that gives an unauthenticated network attacker 200 OK on every endpoint a vulnerable Spring Boot 4.0 application defines — not only /actuator/*, and not only the parts the public advisories enumerate.
We built the lab. We mapped the reach. We wrote the detections. This post is the technical version, written for the defender or engineering lead who has to decide what to do about it before Monday.
TL;DR
The shortest exploitable shape is two lines on the wire:
GET /actuator/heapdump HTTP/1.1
Host: <victim>
If the target is Spring Boot 4.0.0–4.0.5, servlet-based, with spring-boot-actuator-autoconfigure on the classpath but spring-boot-health not on the classpath, and no user-defined SecurityFilterChain @Bean, the response is a ~32 MB Java HPROF dump. That dump contains every live string in process memory: the JDBC URL with embedded password, the Spring Security default user password under {noop} encoding, any in-memory JWT signing key, OAuth client secret, or BCrypt hash.
One round-trip. No auth. No payload engineering.
Three things to internalise immediately:
- The scope is not just actuator. The bug’s
SecurityFilterChainhas nosecurityMatcher. With@Order(BASIC_AUTH_ORDER)it pre-empts the default authenticated chain on every path. Anonymous GETs to your custom/api/<controller>endpoints also return 200 in the vulnerable state. /actuator/envis recon, not exfil, on 4.0. Spring Boot 4.0 redacts every property value to******regardless of name. Property keys still leak verbatim — useful for architecture mapping, useless for direct credential theft. The high-yield endpoint is/actuator/heapdump, where the Sanitizer doesn’t apply.- Reactive (WebFlux) is not exploitable in the same way. In the same dep state,
ReactiveManagementWebSecurityAutoConfigurationthrowsNoClassDefFoundErrorat startup; the application fails to boot rather than silently permitting all. The Spring advisory’s “servlet-based” precondition is empirically accurate.
The bug, source-level
The vulnerable file is module/spring-boot-security/src/main/java/org/springframework/boot/security/autoconfigure/actuate/web/servlet/ManagementWebSecurityAutoConfiguration.java at v4.0.5. The bean method that ships the default management SecurityFilterChain looks (annotated and elided for clarity) like this:
// v4.0.5 — VULNERABLE. The closing brace of the `if` is AFTER the
// authorizeHttpRequests block. The whole authorization call is gated
// behind "is HealthEndpoint on the classpath?".
if (ClassUtils.isPresent("org.springframework.boot.health.actuate.endpoint.HealthEndpoint",
getClass().getClassLoader())) {
http.authorizeHttpRequests((requests) -> {
requests.requestMatchers(healthMatcher(), additionalHealthPathsMatcher()).permitAll();
requests.anyRequest().authenticated();
});
}
http.formLogin(withDefaults());
http.httpBasic(withDefaults());
return http.build();
If HealthEndpoint is not on the classpath, the entire authorizeHttpRequests(...) lambda never runs. The chain returned by http.build() therefore contains no AuthorizationFilter. The bean still gets registered with @Order(SecurityProperties.BASIC_AUTH_ORDER) and no securityMatcher — so it captures every request to every URL and dispatches them through formLogin / httpBasic filters that authenticate but never authorise.
The result: the chain matches /**, accepts anonymous, and pre-empts whatever default authenticated chain Spring Boot would otherwise install. Every endpoint, anonymous, 200.
The patch (commit 874f629, v4.0.6) is structurally trivial:
// v4.0.6 — PATCHED. authorizeHttpRequests now ALWAYS runs.
// The HealthEndpoint check is moved INSIDE the lambda — it gates only
// the permitAll(healthMatcher) line, not anyRequest().authenticated().
http.authorizeHttpRequests((requests) -> {
if (ClassUtils.isPresent("…HealthEndpoint", …)) {
requests.requestMatchers(healthMatcher(), additionalHealthPathsMatcher()).permitAll();
}
requests.anyRequest().authenticated();
});
Same lines of code; one brace moved. A logically equivalent requests.anyRequest().authenticated() always executes, the AuthorizationFilter is back in the chain, and 401 is restored on every path.
This is a useful CVE for engineering culture conversations because it’s the canonical case where a single brace, in code that looks reviewed, fails closed under one classpath shape and fails open under another. The fix is unambiguously correct only after you’ve reasoned about both shapes.
What the public framing gets wrong
Three places where the public advisories are incomplete. Each is defender-utility-positive — a defender makes different decisions once the corrected scope is on the table.
1. The bypass is not scoped to /actuator/*
Both the Spring advisory and most third-party blog write-ups frame the impact as “exposes actuator endpoints.” That’s the most visible symptom; it’s not the limit of the bug.
ManagementWebSecurityAutoConfiguration builds a chain without a securityMatcher. A SecurityFilterChain with no matcher matches every request — that’s the Spring Security contract. With @Order(BASIC_AUTH_ORDER) it sits ahead of SpringBootWebSecurityConfiguration’s default authenticated chain in the filter-chain proxy ordering, and the proxy uses first matching chain wins semantics. So the broken management chain runs for every URL, the application’s intended authenticated chain never gets a turn, and there is no AuthorizationFilter anywhere in the request pipeline.
Empirically: our scenarios/05-app-endpoint-bleeds.js reproduces this against a custom @RestController mapped to /api/secret. Vulnerable lab: 200 with the secret payload. Patched lab: 401. Same code, same controller, same request.
Defender takeaway: a vulnerable Spring Boot 4.0 deployment has no default authorization on any endpoint. The compromise scope at the application layer is the full URL surface. WAF rules that gate only /actuator/* are insufficient.
2. /actuator/env is not the credential-leak path on 4.0 — heapdump is
A common framing in CVE summaries is that /env exposes credentials. That was true for Spring Boot 3.x and earlier; it is not true for 4.0.
Spring Boot 4.0 hardened the default Sanitizer to redact every property value to ******, regardless of property name. The change applies to /actuator/env, /actuator/env/{key}, and /actuator/configprops. Empirically in our lab:
$ curl -s http://localhost:8080/actuator/env | jq '.propertySources[].properties | keys' | head -20
[
"spring.application.name",
"server.port",
"spring.datasource.url",
…
]
$ curl -s http://localhost:8080/actuator/env/spring.datasource.password
{"property":{"source":"applicationConfig","value":"******"}}
The keys leak — useful for architecture reconnaissance (“they have a Redis cluster, a Kafka client, an SMTP relay”) — but the values are sanitised.
/actuator/heapdump bypasses the Sanitizer entirely. It returns a Java HPROF binary written from raw heap memory. Every String instance the JVM holds, including the very property values the Sanitizer just redacted on the way out of /env, is sitting in memory as a plain UTF-16 char array.
Triage prioritisation should reflect this. Heapdump first; env is recon. The CVE-2026-40976 IOC catalogue we ship treats a 200 on /actuator/heapdump as the highest-fidelity Layer-A signal in the entire detection set, and a 1 MB+ response body on a /actuator/* path as the netflow-class confirmation at Layer G.
3. Write-side endpoints are CSRF-blocked in default config — but watch for the risky combination
Spring Security 6 enables CSRF by default. The bug removes the AuthorizationFilter from the management chain, but the CsrfFilter remains. So an anonymous POST /actuator/loggers/<name> or POST /actuator/shutdown returns HTTP 403 with {"error":"Forbidden"} rather than 200.
Our scenarios/04-anon-loggers-tamper.js and 08-shutdown-csrf-blocked.js both confirm the 403. The default config is therefore read-side only exploitable.
But — and this is the part to surface to your application teams — many stateless REST APIs explicitly disable CSRF (http.csrf(c -> c.disable())) because they don’t use cookies for auth. In a vulnerable Spring Boot 4.0 deployment with CSRF disabled, the write-side endpoints are also reachable anonymously. Effective severity in that combination jumps to “critical (DoS)” plus post-exploit log-tampering capability via /actuator/loggers.
Defender takeaway: the sigma-http-actuator-anon.yml:write_attempt rule we publish matches the attempt regardless of disposition. A 403 in your access log is itself an adversary signal — the attacker has identified a vulnerable Spring Boot 4.0 stack and is probing the write surface. Alert on it even when CSRF blocks the action.
The reach map
Confidence is annotated [C] = confirmed (we ran it / read the source), [L] = likely (consistent reasoning, partial evidence).
| Reach (what the attacker controls) | Vulnerable (4.0.5) | Patched (4.0.6) | Conf. |
|---|---|---|---|
Anonymous GET /actuator/env |
PWNED — JSON 200, values redacted, keys leak | 401 | C |
Anonymous GET /actuator/heapdump |
PWNED — full HPROF 200, ~32 MB | 401 | C |
Anonymous GET /actuator/configprops / beans / mappings / threaddump / conditions / info |
PWNED — JSON 200 | 401 | C |
Anonymous GET /api/<custom-controller> |
PWNED — application endpoint anonymous | 401 | C |
Anonymous POST /actuator/loggers/<name> (security-log tamper) |
403 — CSRF blocks default config | 401 | C |
Anonymous POST /actuator/shutdown (DoS) |
403 — CSRF blocks default config | 401 | C |
Same lab + spring-boot-health re-added |
NOT EXPLOITABLE — auth restored | 401 | C |
Same lab + user-defined SecurityFilterChain @Bean |
NOT EXPLOITABLE — @ConditionalOnDefaultWebSecurity backs off |
401 | C |
| WebFlux variant at 4.0.5 with same dep state | NOT EXPLOITABLE — bean fails to construct (NoClassDefFoundError) |
starts cleanly, 401 | C |
| Same vulnerable state but app explicitly disables CSRF | probable PWNED on write side — write_attempt rule matches | 401 | L |
The two NOT EXPLOITABLE rows on health re-added and on user SecurityFilterChain @Bean are doing real work in the wild. Most production Spring Boot 4.0 deployments hit at least one of them. The vulnerable population is roughly: deployments that took an explicit dependency on spring-boot-actuator-autoconfigure without the corresponding spring-boot-health (often as part of slimming a fat JAR), and that rely on the default web security rather than rolling their own. That’s not zero — it’s the population that built actuator support into infra dashboards but doesn’t run the health endpoint behind it.
The reactive case is interesting because the same code shape that produces the bypass under servlet produces a startup crash under reactive. The ReactiveManagementWebSecurityAutoConfiguration.healthMatcher() bean construction evaluates the lambda eagerly, hits the missing HealthEndpoint class, and throws NoClassDefFoundError from inside Spring’s bean factory. Spring Boot fails the application start. Reactive deployments at 4.0.0–4.0.5 in this dep state therefore never made it to production — they crashed in CI or on first deploy. The patch to the reactive class is structurally an availability fix (allowing the bean to construct successfully when HealthEndpoint is missing), not a security fix; the security guarantee was already preserved by virtue of fail-closed.
Detection engineering
The defender brief enumerates IOCs by layer. The condensed version, in detection-engineering order:
Layer A — HTTP request fingerprint. The bug has no payload, so the request line itself is the signal. Anonymous + actuator path + 200 is the structural fingerprint. Sigma rule (excerpt):
detection:
selection_path:
cs-uri-stem|startswith:
- '/actuator/heapdump'
- '/actuator/env'
- '/actuator/configprops'
- '/actuator/beans'
- '/actuator/mappings'
- '/actuator/threaddump'
selection_anonymous:
cs-username: '-'
sc-status: 200
condition: selection_path and selection_anonymous
User-Agent shape is a soft signal worth scoring. Legitimate scrapers use known product strings (Prometheus/, Datadog/, New Relic/); adversary toolkits use curl/, Python-, Go-http-client/, or omit the header entirely.
Layer B — Spring Boot stdout startup banner. Spring emits a single line that announces actuator exposure:
o.s.b.a.e.web.EndpointLinksResolver : Exposing 9 endpoints beneath base path '/actuator'
The count is the IOC. Defaults are 2 (info, health). Anything higher is a CVE-2026-40976 reach-mapping candidate — a stack where the actuator surface is broad enough that the bypass actually matters. Sigma rule sigma-spring-startup.yml matches any count ≥ 3.
Layer D — response body bytes. Three high-fidelity YARA rules ship in the brief:
HPROF_Magic_In_HTTP_Response: matchesJAVA PROFILE 1.0.2\0at offset 0 of any HTTP response body. Unambiguous evidence of a heapdump in flight. CVE-agnostic — any heapdump in any HTTP response is a problem.Spring_Environment_Endpoint_Body: matches thepropertySources/activeProfiles/defaultProfilesJSON shape. Confirms/actuator/envexposure even when the URL is rewritten by an intermediate proxy.Spring_Security_Noop_Password_Marker: matches the{noop}PasswordEncoder marker. Spring Security’s no-op encoder stores plaintext passwords prefixed with{noop}. The marker should never appear in any normal response body — but it appears verbatim in a heapdump. CVE-agnostic; high-fidelity credential-leak signal.
Layer F — process / spawn footprint. None. Exploitation runs entirely inside the existing Java process via the existing Tomcat thread pool. EDR process_create events show nothing new. Defenders relying solely on EDR will miss CVE-2026-40976 on the initial-access path. Pair EDR with Layer A (WAF / access log) and Layer G (netflow size signals) detection.
Layer G — netflow size signal. A 1 MB+ response on a /actuator/* path to a non-management network is netflow-class anomalous. For deployments with proper management-network separation (the recommended posture), this is near-zero false-positive. Useful as a canary even when the request layer is TLS-encrypted to the WAF and unparseable in plaintext.
The full ruleset (Sigma, YARA, ModSecurity / Coraza WAF, plus a single-class Java filter you can drop in as a @Bean to 401 anonymous /actuator/** regardless of the broken security chain) ships in the lab repository alongside this post.
Mitigation order
- Patch.
spring-boot-starter-parent:4.0.6, released 2026-04-23. This is the canonical fix. - If patching is delayed, add
spring-boot-healthas an explicit dependency. One line inpom.xml/build.gradle. Restores authorization on the vulnerable Spring Boot 4.0.5 stack without touching any Java code, because the source-level branch that runs theauthorizeHttpRequestsblock is now satisfied. Verified byscenarios/06-health-restored.js. This is the lowest-friction remediation if you can’t change the parent version today. - If you can’t change the dependency tree, drop in a single-class
@Beanfilter. The brief shipsspring-boot-filter.java— a Spring SecurityOncePerRequestFilterregistered with@Order(SecurityProperties.BASIC_AUTH_ORDER - 1)that 401s anonymous/actuator/**regardless of what the autoconfigured chain does or doesn’t install. Useful for environments with a frozen build pipeline. - WAF rule. Deploy
modsecurity.conf(or its Coraza equivalent) in front of every Spring Boot 4.0 deployment. Two pivots are pre-configured to block anonymous read-side and write-side actuator access; a third pivot is left for the operator to tune to local evasion history. - Run the actuator on a separate management port.
management.server.port=8081plus network ACLs that restrict 8081 to internal monitoring networks. Reduces external blast radius even on vulnerable stacks. Not a substitute for patching — but a meaningful defence-in-depth posture for any actuator-exposing deployment. - Restrict
management.endpoints.web.exposure.include.healthonly is the safest baseline for internet-exposed deployments.*is the worst case and should be aggressively grepped out of every deployment manifest you ship.
Risky combinations to flag in your fleet inventory
These are the combinations that turn the read-side bypass into something larger. None of them is a separate CVE; all of them are configurations a vulnerable Spring Boot 4.0 deployment can plausibly have.
- CSRF disabled in custom chain.
http.csrf(c -> c.disable())is common for stateless REST APIs. Combined with the bypass, the write-side endpoints (/actuator/loggers,/actuator/shutdown,/actuator/restart,/actuator/refresh) become anonymously reachable. Effective severity: critical. - Heap with high-value secrets. Default Spring Security configuration stores the auto-generated default user password under
{noop}encoding. Heapdump leaks it verbatim. Custom configurations usingBCryptPasswordEncoderleak the bcrypt hash — offline-crackable but slower. Either way, heapdump access is effective compromise of any in-memory credential. JWT signing keys, OAuth client secrets, SMTP relays, Redis AUTH strings — anything the application loaded into memory at startup. management.endpoints.web.exposure.include=*plus the CVE state. The default exposure list in 4.0 isinfo,healthonly; an explicit*plus the bug exposes every actuator endpoint anonymously, including/actuator/jolokiaif Jolokia is on the classpath. Jolokia permits arbitrary JMX operations — the chain anonymous → Jolokia → JMXMBeanServer.invoke()is a plausible path to secondary RCE. We did not include Jolokia in this lab; flagged for separate brief.- Cloud Foundry / Tanzu / OpenShift deployments that surface the application directly without a TLS-terminating proxy inspecting
/actuator/*. Spring ships acloudfoundryactuator endpoint with platform-specific exposure defaults that may amplify or modify the reach map. We did not test cross-platform reach in this lab.
Why this CVE is worth re-examining even if you’ve already patched
The 4.0.6 release was last week. Most well-instrumented enterprises will have patched by now. Two reasons to walk the lab anyway:
First, the fleet inventory question is harder than the patch question. Discovering which of your microservices currently runs Spring Boot 4.0.x with the vulnerable dep state is a fat-JAR auditing problem, not a mvn dependency:tree problem. The defender brief ships a tools/heapdump-canary.sh that probes a candidate URL and reports pwned / patched / unreachable for each target — useful for the runtime audit you should run after the build-time audit.
Second, the detection coverage question outlives the patch window. The same anonymous-actuator detection rules catch the next CVE in this family (and there will be one — actuator security has had this shape of failure mode multiple times across the 1.x→2.x→3.x→4.x timeline). The detections we built for this CVE are CVE-agnostic at the response-body layer (HPROF magic, Spring environment JSON shape, {noop} marker) — deploying them now buys forward coverage, not just retrospective coverage on this incident.
What you can take away today
- Confirm your Spring Boot 4.0 lockfile resolves
spring-boot-starter-parentat≥ 4.0.6. If it pins below, schedule the upgrade. - For services where the upgrade is delayed beyond the next deploy window, add
spring-boot-healthas an explicit dep. Rebuild. Verify with the canary tool. - Deploy the WAF rule and the Sigma rules ahead of finishing the rollout. The exploit shape is two lines on the wire — your detection should not require the patched code path to fire.
- Treat any 200 on
/actuator/heapdumpfrom a non-management network as an active incident. The HPROF body itself is evidence; preserve it. - Audit any application that explicitly disables CSRF (
http.csrf(c -> c.disable())) on a Spring Boot 4.0.x version. That combination promotes this CVE from read-side to read+write.
The lab, the rules, and the source-level walk live in our research-labs and detection-rules repositories. We rebuilt the bug because the public framing — three tabs of advisories and a CVSS score — wasn’t enough to triage a real fleet from. The reach map is the artefact you actually need.
If you’re staring at a Spring Boot 4.0 estate and trying to prioritise which services need attention this week, request a scoping and we’ll walk through the inventory question with you. The brief, the detections, and the Java filter ship to defenders for free; the audit is what you commission us for.