The error page that named the cluster
Severity: CVSS 7.5 (High) · CWE-200, CWE-668, CWE-16 Impact: an exposed Spring Boot Actuator leaked an internal Kubernetes service name, an internal RPC endpoint and its parameters, and the backend auth model. All unauthenticated. Reach: three public API hosts, multiple reproducible leak paths.
Spring Boot, the framework behind a large share of the world's Java backends, ships a module called Actuator. It exposes operational endpoints: health checks, configuration properties, environment variables, thread dumps, metrics. They are invaluable for the team running the service and radioactive if they face the internet. The rule is simple. Actuator belongs on an internal management port, behind authentication, never on the public 443. On a mid-size cryptocurrency exchange, three backend hosts broke that rule, and one of them drew an attacker a map of the internal network.
Recon
I started with subdomain enumeration, resolved and probed the live hosts, and fingerprinted the stacks. About thirteen subdomains answered. Three were Spring Boot backends. On a Spring Boot host the first thing I check is /actuator.
$ curl -s https://<futures-api>/actuator
{"_links":{"self":{"href":"http://<futures-api>/actuator","templated":false},
"health":{"href":"http://<futures-api>/actuator/health"},
"info":{"href":"http://<futures-api>/actuator/info"}}}
That HATEOAS JSON is Actuator confirming itself: the endpoints are discoverable and public. Note the http:// in the self link. The host terminates TLS at a proxy and speaks plain HTTP to whatever sits behind it.
The leak: an error page that named an internal service
The interesting endpoint was /actuator/configprops. When I requested it, the service behind it was slow, and instead of a clean response Spring returned its default Whitelabel Error Page carrying the underlying message:
$ curl -s "https://<futures-api>/actuator/configprops"
... Whitelabel Error Page ...
Read timed out executing POST http://futures-base-service/rpc/get_broker_id?hostUrl=<futures-api>
That one line is a gift. It tells me an internal Kubernetes service is named futures-base-service (the http://name/ form with no domain is the in-cluster DNS convention), that it exposes an internal RPC endpoint /rpc/get_broker_id, that the RPC takes a hostUrl parameter, and that the public host is a thin gateway fanning requests out to internal services over plain HTTP. None of this should ever be visible from outside.
The fix that wasn't: bypassing the custom error handler
A few hours later the same request behaved differently. The team had clearly noticed. Now /actuator/configprops returned a clean custom JSON error instead of the leaky Whitelabel page:
{"code":"-1","data":null,"msg":"The information of the merchant is incorrect, please contact the administrator","msgData":null,"succ":false}
It looked fixed. It was not. The custom handler matched the exact path /actuator/configprops. Spring Boot normalizes request paths, collapsing // and decoding %2F, but it does that on the actuator route after the custom handler has already decided the request did not match. Two trivial variations brought the leak straight back:
# double slash
$ curl -s "https://<futures-api>/actuator//configprops"
# -> 200, full Whitelabel page, futures-base-service leaked again
# URL-encoded trailing slash
$ curl -s "https://<futures-api>/actuator/configprops%2F"
# -> 200, full Whitelabel page, futures-base-service leaked again
The team patched the symptom (one exact string) instead of the cause (Actuator exposed, Whitelabel enabled). Path-normalization mismatches between a security check and the router behind it are a recurring bug class: the guard inspects one string, the thing it guards resolves a different one. The fix made the finding look closed in a casual retest while leaving it fully open to anyone who adds a slash.
A second, quieter leak: the error envelope
Twenty-plus unmatched paths returned the same internal error JSON. On its own, low severity. But for an attacker doing recon it confirms several things for free: the backend auth model is merchant-ID based ("the information of the merchant is incorrect"), there is an internal admin role ("contact the administrator"), and the response envelope is {code, data, msg, msgData, succ}, which is the shape you would forge or fuzz against next. Error messages are documentation you did not mean to write.
What I proved, and what I did not
The Whitelabel line shows the gateway making an internal POST to futures-base-service/rpc/get_broker_id with a hostUrl parameter. A parameter literally named hostUrl on an internal RPC is the textbook shape of a server-side request forgery sink: if a client can set it, the gateway can be steered into calling arbitrary internal or cloud-metadata URLs. I tested that. In my testing the hostUrl value was set server-side to the host's own name, and the values I supplied were not reflected into the outbound request, so I could not confirm attacker-controlled SSRF from outside. The proven impact is information disclosure. The SSRF-shaped sink is a real risk that this same misconfiguration points straight at, and the people positioned to confirm whether hostUrl is reachable are the ones with the source. A finding you can defend beats one you have to walk back.
Why it matters
Information disclosure gets shrugged off because nothing was "stolen." Reframe it for an exchange. An attacker's hardest job against a hardened platform is mapping the inside: which services exist, what they are called, how they talk, what the auth model is. This host handed over the in-cluster name of a core service, an internal RPC with its parameters, the gateway-to-service topology, and the auth scheme, to anyone with curl. That is the reconnaissance phase of a real intrusion completed for free. It is also precisely the internal-topology exposure that ICT-risk regimes like DORA and NIS2 expect a financial entity to control. The actuator was not the breach. It was the map an attacker draws before one.
The fix
# application.yml
management:
endpoints:
web:
exposure:
include: "" # expose nothing on the public app
endpoint:
health:
show-details: never
server:
port: 9001 # actuator on a separate, cluster-only management port
server:
error:
whitelabel:
enabled: false # never return stack traces or internal URLs to clients
- Take Actuator off the public host. If ops needs it, bind it to a separate management port reachable only from inside the cluster, behind authentication.
- Disable the Whitelabel Error Page and return a generic, detail-free error from a global handler that runs after path normalization, not from a string match on one path.
- Treat internal service names, RPC paths and stack traces as secrets in any client-facing response.
- If an internal RPC takes a URL parameter, allowlist its targets server-side. Never let a client influence an outbound request host.
Find it on your own estate
# 1. Ask Spring whether Actuator is reachable on every public host:
$ curl -s https://<host>/actuator | jq .
# 2. If a custom error handler "fixed" a leak, test the normalization bypass:
$ curl -s "https://<host>/actuator//configprops"
$ curl -s "https://<host>/actuator/configprops%2F"
# 3. Read the error body on junk paths; if it names a role or an auth model,
# that is disclosure too:
$ for p in /robots.txt /favicon.ico /.well-known/x; do curl -s "https://<host>$p"; echo; done
The strongest part of this finding was not the leak. It was the bypass. A patch that matches one exact string is not a fix, it is a label. When you close an information-disclosure bug, close the cause, and retest it the way an attacker would: with a slash where the handler was not looking.