CyberNugget

L38 · Security misconfiguration

Module 5: Web Application Security · ~20 min · Lesson 38 of 71

🎯 What you'll learn

  • What "security misconfiguration" covers, layer by layer, and why it's A02 in the OWASP Top 10:2025
  • Four classic forms: verbose errors, default credentials, exposed debug endpoints, directory listing
  • Exactly what Spring Boot Actuator's /actuator/env and /actuator/heapdump give away
  • A Spring Boot and Kong setup that keeps the useful endpoints and locks down the rest

🔥 Why this matters

In December 2024, researchers from the Chaos Computer Club (CCC) described how they reached location data for vehicles from Volkswagen Group brands. No exploit was needed. A Spring Boot application run by Cariad, the group's software company, exposed its Actuator endpoints to the internet, including /heapdump. The heap dump contained AWS credentials in plain text, and those led to the data: roughly 800,000 electric vehicles in press reports, with precise locations for about 460,000. Cariad said it closed the gap quickly. One property in a YAML file was the whole vulnerability.

1. Every layer has settings, and every setting has a default

A security misconfiguration is a weakness where a secure option existed but nobody chose it: a default left on, a debug flag that reached production, a feature nobody needed but everybody got. The code can be perfect. OWASP's 2025 list moved it up to A02 because more and more behaviour now lives in configuration (L29).

 ┌──────────────────────────────────────────────────────────────┐
 │ App code      error handlers, debug flags, feature toggles   │
 │ Framework     Spring Boot Actuator, Swagger UI, dev profiles │
 │ Gateway       Kong routes, Admin API, CORS (L37), headers    │
 │ Kubernetes    Service type, root containers, SA tokens       │
 │ Data stores   MongoDB auth and bindIp (L53)                  │
 │ Cloud         S3 public access, security groups, IAM         │
 └──────────────────────────────────────────────────────────────┘
   each row: a default, a dev value, a prod value. Drift between
   the last two is where most findings come from.

Misconfigurations come from four places: insecure defaults (the product ships open), leftover debug settings (fine on a laptop, copied to prod), unneeded features (enabled because the starter dependency turned them on), and drift (someone fixed prod by hand, and the next deploy undid it).

2. Verbose errors

A client sends a malformed request, and your service answers:

HTTP/1.1 500 Internal Server Error
Content-Type: application/json

{"timestamp":"2026-11-07T10:14:03Z","status":500,
 "error":"Internal Server Error",
 "trace":"com.mongodb.MongoSocketOpenException: Exception opening
  socket\n at com.mongodb.internal.connection.SocketStream.open...
  ...caused by: java.net.UnknownHostException:
  mongo-0.mongo.payments.svc.cluster.local ...",
 "path":"/v1/payments/PAY-7781"}

That body tells an attacker the framework, the database and its driver, an internal hostname, the namespace layout, and which inputs break your code. For SQL it can even leak data (L30). It also fits the new A10:2025, Mishandling of Exceptional Conditions (L29).

Spring Boot leaves the stack trace and message out of error responses by default (since 2.3). The leaks come from a dev profile with server.error.include-stacktrace: always that reaches prod, or an @ExceptionHandler that returns e.getMessage(). The fix is a fixed-shape error with a correlation ID, the detail going only to your logs:

{"type": "about:blank", "title": "Internal Server Error",
 "status": 500, "correlationId": "c0a8-4f1e-9b7d"}

This shape is RFC 9457 "problem details", which Spring 6 supports as ProblemDetail. Support staff search the logs for the ID; attackers learn nothing.

3. Default credentials and insecure defaults

Products ship with settings that make the first five minutes easy:

Product The default What to do
Airflow (Docker Compose quick-start) Web login airflow / airflow Never deploy the quick-start; use SSO
Grafana admin / admin (asks you to change it, but you can skip) Set a strong admin password or SSO at install
MongoDB Authorization off unless security.authorization: enabled; binds to localhost since 3.6 Turn auth on everywhere, including dev (L53)
Kong Admin API No authentication in open-source Kong; listens on 127.0.0.1:8001 Keep it on loopback (second deep dive)

Notice the Mongo and Kong rows: the default is safe only because of where it listens. One change to bindIp: 0.0.0.0 or KONG_ADMIN_LISTEN=0.0.0.0:8001 (as in Kong's Docker trial examples) and the missing password becomes the whole story.

4. Exposed debug endpoints: Spring Boot Actuator

Spring Boot Actuator adds operational endpoints under /actuator. A few are made for production (health, info, prometheus); others are for debugging and are dangerous if reachable:

Endpoint What it gives away
/actuator/env Every property and environment variable name, with values (masked by default since Boot 3.0)
/actuator/configprops Bound configuration: datasource URLs, client IDs
/actuator/heapdump A full copy of the JVM's memory: every secret the app holds
/actuator/loggers POST can switch a logger to DEBUG, so tokens start appearing in logs
/actuator/mappings A map of every route, including the undocumented ones
/actuator/gateway Spring Cloud Gateway routes; CVE-2022-22947 turned it into code execution

Here's the typical attack path; the CCC researchers' published account has the same shape:

 1. GET /actuator              ─▶ list of exposed endpoints
 2. GET /actuator/env          ─▶ names: MONGO_URI, AWS_ACCESS_KEY_ID
                                  values: "******" (masked)
 3. GET /actuator/heapdump     ─▶ 150 MB .hprof file
 4. strings dump.hprof | grep  ─▶ the real MONGO_URI, AWS keys,
                                  JWTs from requests in flight
 5. use the keys               ─▶ S3, MongoDB, other services

Step 2 is why masking isn't enough: /env hides values, but /heapdump holds the live objects that contain them. (The first deep dive shows this on your own JVM.)

Defaults matter, and they changed over time. Since Spring Boot 2.0, only health (plus info, until 2.5) is exposed over HTTP by default. Boot 3.0 masks every env and configprops value by default. Boot 3.4 added an access property per endpoint. Boot 3.5 sets heapdump access to none by default. So an exposed heap dump today almost always means someone wrote exposure.include: "*", often to "just get metrics working".

5. Directory listing

When a web server is asked for a folder with no index file, it can list the folder's contents: backups, .env files, old exports. nginx (autoindex) and Tomcat (listings) have it off by default; python3 -m http.server has it on, and an S3 bucket policy granting s3:ListBucket to everyone is the cloud version. Turn it off, and keep S3 Block Public Access on at the account level.

🧪 Worked example: locking down Actuator on EKS

Before (a quote service, copied from a tutorial):

management:
  endpoints:
    web:
      exposure:
        include: "*"              # every endpoint, on port 8080
  endpoint:
    env:
      show-values: always         # unmask /env
    health:
      show-details: always        # DB hosts, disk paths
server:
  error:
    include-stacktrace: always

Kong routes /quotes/** to the pod's port 8080, so /quotes/actuator/heapdump works from the internet.

After:

management:
  server:
    port: 8081                    # separate port, not routed by Kong
  endpoints:
    web:
      exposure:
        include: health,prometheus
  endpoint:
    health:
      show-details: never
    heapdump:
      access: none                # Boot 3.4+ (older: enabled: false)
server:
  error:
    include-stacktrace: never
    include-message: never

Then make the port boundary real in Kubernetes. The Service that Kong targets lists only port 8080. The kubelet's probes and Prometheus reach 8081 directly, and a NetworkPolicy allows 8081 only from the monitoring namespace (L51). Finally, test it from your laptop against your own dev route:

$ for p in health env heapdump; do
    curl -s -o /dev/null -w "$p %{http_code}\n" \
      "https://dev-api.example.com/quotes/actuator/$p"
  done
health 404
env 404
heapdump 404

All three should be 404 through the gateway, even health, which now lives only on 8081. A 200 for health would mean port 8080 still serves Actuator, and the next include: "*" would be public.

🛠 Try it (optional, ~5 min)

If you have a Spring Boot app you can run locally, start it and list what Actuator exposes:

curl -s http://localhost:8080/actuator | \
  python3 -c 'import json,sys; print(*json.load(sys.stdin)["_links"])'

A minimal safe answer looks like:

self health health-path prometheus

Anything like env, heapdump, loggers, configprops or threaddump deserves a look in application.yml and in every profile file (application-dev.yml included, since profiles leak). If you get a 404, Actuator may be on a separate management port: check management.server.port.

🧠 Key takeaways

  • Misconfiguration means a secure option existed and nobody chose it; it lives at every layer.
  • Return fixed-shape errors with a correlation ID; keep stack traces in logs.
  • Defaults are safe only while they stay default: bindIp, admin_listen and exposure.include.
  • /actuator/heapdump defeats all masking; expose only health (and metrics), on a separate port.
  • Verify from outside: every /actuator path through the gateway should be 404.

📖 Words to know

Term Meaning
Security misconfiguration A weakness caused by settings, not code: defaults, debug flags, unneeded features
Configuration drift Environments diverging from the intended config, often through manual fixes
Verbose error An error response that reveals internals such as stack traces, hosts or versions
Correlation ID A unique ID returned to the client and written to the logs, linking the two
Spring Boot Actuator Spring Boot's operational endpoints for health, metrics and debugging
Heap dump A file containing every object in a JVM's memory at one moment
Management port A separate port (management.server.port) for Actuator, kept off public routes
Directory listing A web server showing a folder's file list when there's no index page

⏭ Next time

L39 opens Module 6, API Security: why APIs got their own OWASP Top 10, and a tour of what's on it.

Deep dive 1

What's inside a heap dump (try it on your own JVM)

/env masks values, so why is /heapdump so much worse? Because a heap dump isn't a report written by Spring. It's a raw copy of the memory where Spring keeps everything it reads.

What ends up in the heap

A heap dump is a snapshot of every live object in the JVM, written as an HPROF file on HotSpot JVMs (Spring Boot's docs note OpenJ9 writes PHD instead). Anything the app holds as a String, byte[] or char[] is in there:

 JVM heap (what /heapdump copies)
 ┌──────────────────────────────────────────────────────────┐
 │ config     MONGO_URI with password, Kafka/SASL secrets   │
 │ env        ALL environment variables, not just the ones  │
 │            the app asked for (cached on first getenv)    │
 │ clients    AWS SDK credentials, OAuth client secret      │
 │ requests   Authorization headers, JWTs, request bodies   │
 │ data       cached customer records, session attributes   │
 └──────────────────────────────────────────────────────────┘
   masking in /env never touches any of this

The env row surprises people. On the JDK, the first call to System.getenv(...) loads the whole process environment into a map, and Spring Boot reads the environment at startup. So a secret injected as an environment variable is in the heap even if your code never reads it.

🧪 Worked example: find a fake secret in your own heap

This runs entirely on your machine with a JDK (11 or later) and fake values. In a throwaway folder, save Leaky.java:

public class Leaky {
    public static void main(String[] args) throws Exception {
        String user = System.getenv("APP_USER"); // reads ONE var
        String token = "Bearer eyFAKE.l38-demo-token";
        System.out.println("pid " + ProcessHandle.current().pid());
        Thread.sleep(600_000);
        System.out.println(user + token);
    }
}

Compile it, run it with two fake environment variables, and dump its heap with jcmd (part of the JDK):

mkdir -p ~/tmp/heap-l38 && cd ~/tmp/heap-l38   # put Leaky.java here
javac Leaky.java
APP_USER=quotes MONGO_PASSWORD=FAKE-l38-Pa55 java -cp . Leaky &
sleep 2
jcmd $! GC.heap_dump "$PWD/leaky.hprof"
strings leaky.hprof \
  | grep -oE 'MONGO_PASSWORD|[A-Za-z.-]*l38[A-Za-z0-9.-]*' \
  | sort | uniq -c
kill $!

Real output (Temurin JDK 25 on macOS; the dump was about 4 MB):

   2 eyFAKE.l38-demo-token
   2 FAKE-l38-Pa55
   2 MONGO_PASSWORD

The program only read APP_USER, yet MONGO_PASSWORD and its value are in the dump, and so is the token. strings works so easily because since JDK 9, Latin-1 strings are stored one byte per character ("compact strings"). The file was created with owner-only permissions (-rw-------), a hint at how the JDK treats it. jcmd won't overwrite an existing dump, so delete leaky.hprof before a second run, and delete the folder when you're done.

Where else heap dumps leak

The /heapdump endpoint is the dramatic case, but dumps also escape through ordinary operations:

  • -XX:+HeapDumpOnOutOfMemoryError writes a dump to a pod volume, which may be collected by a log shipper or copied to an S3 bucket "for analysis".
  • Dumps get attached to tickets and chat threads during an incident.
  • Profilers and APM agents can capture them on demand.

Treat a heap dump as containing every secret the service has, because it does: restricted storage, short retention, and never a ticket attachment.

Making a leak hurt less

Two defences limit the damage when a dump does escape. Short-lived credentials: a pod using IRSA or EKS Pod Identity holds AWS credentials that expire within hours, while a static access key in an environment variable works until someone notices. Rotation on exposure: if a dump left its intended home, rotate everything it could contain (L19), then check CloudTrail and MongoDB audit logs for use of the old values (L52).

🧠 Extra takeaways

  • A heap dump holds every in-memory secret; Actuator masking doesn't apply to it.
  • The JDK caches the full environment, so every env var is in the heap, read or not.
  • OOM dumps, tickets and APM tools leak dumps too; treat them as secret-class data.
  • Prefer short-lived credentials (IRSA, Pod Identity) so a leaked dump expires.
Deep dive 2

The Kong Admin API: one port that controls every route

Kong has two faces. The proxy (ports 8000/8443) carries the API traffic. The Admin API (8001/8444 by default) is the control plane: it creates and changes services, routes, consumers, credentials and plugins. In open-source Kong it has no authentication of its own. Whoever can reach it is the gateway administrator.

What an attacker does with it

 attacker ──▶ :8001 Admin API (reachable by mistake)
   │
   ├─ 1. GET /consumers, /key-auths ─▶ consumer names and API keys
   ├─ 2. DELETE a route's auth plugin ─▶ /v1/payments now public
   ├─ 3. PATCH a service's host ─▶ traffic sent to attacker's server
   └─ 4. POST /plugins (http-log to attacker URL)
                         ─▶ details of every request, copied out

None of this needs a vulnerability in Kong. The Admin API is doing exactly its job. It also leaves the services looking healthy, which makes step 4 hard to spot: requests still succeed, they're just also being copied (whether request headers such as Authorization appear in the log depends on the plugin and Kong version, so assume the worst).

How it gets exposed

Kong's default admin_listen is 127.0.0.1:8001 (and 127.0.0.1:8444 for TLS): loopback only. It gets exposed in a few predictable ways:

  • KONG_ADMIN_LISTEN=0.0.0.0:8001 copied from a Docker quick-start into a real deployment.
  • In Kubernetes, a kong-admin Service of type LoadBalancer or NodePort, or an Ingress for it.
  • A security group that opens 8001 to the VPC "for the pipeline", making it reachable from every compromised pod or laptop on the network (L12).

🧪 Worked example: checking a cluster that runs Kong

In a dev cluster you're authorized to use (namespace names vary):

$ kubectl get svc -n kong
NAME          TYPE           CLUSTER-IP     PORT(S)
kong-proxy    LoadBalancer   10.100.12.7    80:31080/TCP,443:31443/TCP
kong-admin    LoadBalancer   10.100.40.3    8001:30801/TCP   ◀ finding

A kong-admin Service of type LoadBalancer gives the Admin API an AWS load balancer. Even an internal one is reachable from anything in the VPC. The fix is to make it ClusterIP (or remove it), and to allow the admin port only from the pods that need it with a NetworkPolicy. If you run the Kong Ingress Controller, it does need to reach the Admin API (recent versions use a headless ClusterIP Service with mTLS between controller and gateway); that's expected, but nothing else should.

Locking it down

Kong's "secure the Admin API" guidance boils down to layers:

Layer Control
Listen Keep admin_listen on loopback, or a private interface only
Network NetworkPolicy, security groups, no public or VPC-wide load balancer
Loopback proxy Expose it only through a Kong route of its own with key-auth/mTLS and ip-restriction
Identity RBAC on Admin API resources (a Kong Enterprise feature), or Konnect
Change control Declarative config with decK from git, so manual changes show up as drift

The last row turns the Admin API from "how we change things" into "how the pipeline applies what was reviewed". In DB-less mode, Kong's Admin API is read-only apart from POST /config, which replaces the whole configuration, so that endpoint is the one to guard.

🧠 Extra takeaways

  • The Kong Admin API has no authentication in open-source Kong; network position is the control.
  • With it, an attacker can strip auth, reroute services and copy traffic, without a single CVE.
  • Look for 0.0.0.0 listeners and LoadBalancer/NodePort Services for 8001/8444.
  • Apply config from git with decK and treat manual Admin API changes as incidents.

The quiz for this lesson and the Tests section need JavaScript.