In partnership with

TL;DR: The bank-vaults vault-secrets-webhook copies the vault-addr annotation into its Vault client unchecked, then logs in by posting its own service account token to that address. Whoever catches it can then ask the API server for a login token for any account in the cluster, including admin ones. One Secret in a watched namespace starts it. The advisory gets the fixed version wrong and blames the wrong annotation. Also: three Terraform MCP Server CVEs, and one reusable key that enrolled 181 machines.

One thread: some credentials exist to create other credentials. Those are the ones worth finding first.

// attack

🎯 Attack of the Week: the webhook hands over its own identity

GitHub Advisory GHSA-r2v3-8gwf-7ghm, CVE-2026-54725, reported by 0xVijay, entered the Advisory Database [2026-07-31]. The version analysis and source reading below are mine.

The unchecked field. The webhook reads your annotations to inject Vault secrets into workloads. The flaw is in parseVaultConfig(), which copies the vault.security.banzaicloud.io/vault-addr annotation into the Vault client "without any validation or allowlist," per the advisory.

What the attacker writes. A Secret with that annotation and one value the injector recognises: a vault: or >>vault: prefix, or an inline ${vault:...}. Without one, secretNeedsMutation() returns early and no client is built. One extra line in a manifest.

Then the default auth method takes over. It's jwt, so the SDK reads /var/run/secrets/kubernetes.io/serviceaccount/token, the webhook's own identity, and sends it to whatever address you gave it. It goes out in the login body before the server answers. Anything that logs a request body captures it.

That token matters because the chart ships this ClusterRole unconditionally:

- apiGroups: [""]
  resources: ["serviceaccounts/token"]
  verbs: ["create"]

Cluster-wide, with no resourceNames limit. That permission lets the holder ask the API server for a login token for any service account, including whatever your admin workloads run as. The chart mounts no custom-audience token either, so what leaks is the ordinary one Kubernetes projects into the pod. Check how long it lasts: --service-account-extend-token-expiration defaults to true, stretching it to a year. It dies when the pod is deleted.

The advertised path is narrower than the summary says. A second annotation names a service account, and the webhook creates a token for it. The summary calls that cluster-wide theft, though the advisory's own Impact section scopes it to "any SA in their namespace." The code agrees with the body: ObjectNamespace is ar.Namespace, filled in by the API server. That token carries a 600-second TTL and an audience of https://kubernetes.default.svc, which you can compare against your apiserver's --api-audiences. One exception worth checking: if the target service account still has a legacy token Secret, the webhook hands over that instead, and those don't expire at all.

What you need: Secret write in a watched namespace, a recognised vault: value, and webhook egress to your endpoint. ConfigMaps are out, since configMapMutation defaults to false. The namespaceSelector skips kube-system and, by default, the webhook's own release namespace.

Check your version against the code. The advisory's own two fields disagree. "Affected <= 1.22.2" sits next to "Patched 1.23.1", and the first is a curator field that lags. One command settles it: git show v1.23.0:pkg/common/addr.go errors, because the file holding the validation is created in the 1.23.1 diff. If you're on 1.23.0, the advisory is telling you you're fine.

CVSS 9.6 with a scope change. The score holds. What the advisory misses is that you never need vault-serviceaccount, because the default path hands over a better token unprompted.

The patch with no advisory. 1.23.1 shipped 2026-06-09 under the heading "Breaking change: object-supplied Vault addresses are now validated (SSRF hardening)." No CVE, no advisory link. For 52 days your only signal was a breaking change, the upgrade a busy team puts off. The allowlist never covered pod injection either, and that fix (f457844, 2026-07-08) is in no released tag.

One annotation, one vault: value, and the webhook's own projected token leaves the cluster.

Ship This Week. Start with the audit; it pays off whether or not you run this webhook:

kubectl get clusterroles -o json | jq -r '
  .items[] | .metadata.name as $n | .rules[]?
  | select(
      (.apiGroups[]? | . == "" or . == "*") and
      (.resources[]? | . == "serviceaccounts/token" or . == "*" or . == "*/token") and
      (.verbs[]? | . == "create" or . == "*"))
  | "\($n)\t\((.resourceNames // []) | if length == 0 then "UNRESTRICTED" else "scoped:"+join(",") end)"
' | sort -u

Six built-ins always come back: cluster-admin, admin, edit, system:aggregate-to-edit, system:node and system:kube-controller-manager, plus whatever your distribution adds. Look at everything else, then check who is bound to it; the binding is what grants access. rbac-police has a policy for this; most CI linters skip it.

Then upgrade to 1.23.1, set VAULT_ADDR_ALLOWLIST explicitly (without it only the operator address is accepted), and check your image source. The legacy banzaicloud/vault-secrets-webhook on Docker Hub stopped shipping three years ago.

// rule

🚨 Rule of the Week

A sloppy attacker writes their own address into your logs. The SDK logs failed to request new Vault token with a *url.Error carrying the full URL, retrying once a second until timeout. vault_client_auth_attempts_errors_total moves at the same time, though check you can see it: the chart ships metrics.enabled: false and serves /metrics on the TLS webhook port.

A careful one leaves nothing there, so catch the replay instead. Kubernetes stamps any use of an extended service account token past its original expiry with the audit annotation authentication.k8s.io/stale-token and increments serviceaccount_stale_tokens_total. A stolen webhook token used days later trips both.

Your audit log records the token request itself:

verb == "create"
  AND objectRef.resource == "serviceaccounts"
  AND objectRef.subresource == "token"
  AND user.username == "system:serviceaccount:{vault-namespace}:{webhook-sa}"
  AND objectRef.namespace NOT IN {namespaces the webhook legitimately serves}

The universal audit first, then inventory, the egress pin, and detection. Only the first pays off whether or not you run this webhook.

Do this Monday: pin the webhook's egress with a NetworkPolicy allowing only your real Vault endpoint. A repointed client then has nowhere to send anything, which beats alerting on it after the fact.

// defender

🔧 Defender's Corner

Sandbox the agent like it handles live exploits, because it might. Matt Moore at Chainguard, "This Shit is Hard: How Chainguard is sandboxing Athena" [2026-07-29].

Athena patches AI-discovered zero-days before disclosure, so its agents write and test real exploits. Chainguard's answer: one hardware-virtualized microVM per job, QEMU/KVM with its own kernel, on the argument that containers don't contain. Fresh filesystem each time, SSH keys destroyed after, long-lived credentials never inside the guest, egress default-deny at the hypervisor with the first forbidden connection tearing the VM down. Moore names the tradeoff: one shared sandbox primitive concentrates the blast radius.

Three moves port anywhere: ephemeral environment, credentials out of the guest, default-deny egress that includes 169.254.169.254.

The categories carry it. "Unused" means nothing references it, catching toJSON(secrets) and secrets: inherit alongside plain secrets.NAME. "Stale" means no referencing workflow has run in 90 days. "OIDC-replaceable" means it feeds aws-actions/configure-aws-credentials, google-github-actions/auth, azure/login, or an npm or PyPI publish with trusted publishing.

The dashboard is Enterprise-tier, so a manual pass may be your route: grep each repo's workflows, knowing grep misses the dynamic lookups. "Unused" reflects analyzed workflows only, so confirm nothing external pulls it over the API before you delete.

// agent

🤖 Agent Bench: who can repoint your admission webhooks

Point an agent at your cluster configs (kubeconfig contexts, Terraform, Helm values, RBAC manifests) and ask for four lists:

  1. Every mutating and validating webhook, the namespaces each watches, and the service account each runs as.

  2. Every binding granting serviceaccounts/token: create, and who holds it.

  3. Everyone with create or update on ConfigMaps or Secrets in those namespaces. Include CI service accounts; they count double.

  4. Which webhooks have a NetworkPolicy pinning their egress, and to what.

What you walk away with: the identities that can aim each webhook, and the webhooks free to dial anywhere.

Agents miss aggregated ClusterRoles and confuse namespace-scoped with cluster-scoped bindings, so verify every claimed permission with kubectl auth can-i --as=system:serviceaccount:{ns}:{sa} first. Nothing in my shipped tooling covers this surface yet.

Try it this week: run list two on your busiest cluster. Any workload that can request tokens cluster-wide and isn't a secrets controller owes you an explanation.

// radar

📡 Also on the Radar

HashiCorp, "HCSEC-2026-23 - Multiple vulnerabilities impacting HashiCorp Terraform MCP Server" [2026-07-28], CVE-2026-16496 reported by Juan Pablo Martinez Kuhn of Coinspect.

Three flaws, all in the streamable-HTTP transport. On stdio, none reach you. CVE-2026-16498 scores 10.0 (HashiCorp as CNA): stateless mode lacks session isolation, so the first caller's Terraform token gets reused for everyone after. CVE-2026-16496 is the stateful-mode twin, keying the credential cache on session ID alone. CVE-2026-14869 pairs an unauthenticated, query-parameter Terraform address with the server's bearer token.

Affected 0.2.1 through 1.0.x, fixed in 1.1.0. If you moved this off stdio to serve a team, rotate every token that passed through it, restrict the listener, and treat MCP session IDs as bearer credentials.

Pennarun puts it in a section heading: "That long-lived auth key didn't need to exist." One reusable key sat among the 136 the intruder could reach. Over several days it enrolled 181 nodes into the tailnet, each with a CI tag carrying everything a CI node can reach. His fix list: one-off keys, OAuth clients for short expiry, narrow tags, and an ACL audit of what those keys grant. The detection note ports anywhere: flow logs into a SIEM configured for them, because a compromised node might not send logs but every node it talks to does.

The post leaves out the key's age and what that CI tag reached. The same audit applies to Kubernetes bootstrap tokens, self-hosted runner registration tokens, and Vault AppRole secret IDs with unlimited secret_id_num_uses.

A webhook hands over its own service account token, and that token opens every other account. An MCP session ID hands over the last user's Terraform credentials. One reusable auth key put 181 machines on a corporate network. Each of these was a small credential that could reach a much larger one. Go find yours.

Full issue with sources: join.defensive.works/p/your-secrets-webhook-hands-out-its-own-token

Until next Tuesday,
R.K.

// end of issue 016

Sponsored content. Not part of Weekly Recon editorial.

1,000+ Claude Prompts Top Professionals Actually Use at Work

Claude can be your analyst, editor, and strategist.

But most professionals are using it to fix grammar.

These 1,000+ Claude prompts take it from grammar tool to your most powerful AI work assistant.

Sign up for Superhuman AI and get:

  • 1,000+ ready-to-use Claude prompts to get real work done in minutes — researched, tested, and used by professionals at Google, Microsoft, and NASA

  • Superhuman AI newsletter (4 min daily) so you keep learning new AI tools and skills to stay ahead in your career — the prompts are just the beginning

How was this issue?

Login or Subscribe to participate

Keep Reading