BrickellTechnologies

Research

CVE-2026-110720: A ServiceAccount Name Was Enough to Become Consul Admin

Elias Hasas · ·8 min read

When you install Consul on Kubernetes with the Helm chart’s managed ACLs turned on, the chart sets up Consul logins for its own components: the connect injector, the snapshot agent, the mesh gateway and several more. Each login rule said, in effect, “a Kubernetes ServiceAccount called consul-connect-injector gets the connect injector’s role.” None of them said which namespace that ServiceAccount had to be in. Kubernetes only keeps ServiceAccount names unique within a namespace, so a user who could create one in their own namespace could give it that name, log in to Consul with its token, and receive the injector’s ACL role. That role includes acl = "write", which is enough to mint a global-management token. In a shared cluster, an ordinary namespace tenant could make themselves a full Consul administrator.

We reported this to HashiCorp on 7 September. They fixed it in consul-k8s 2.1.0 and 1.8.18, and published the bulletin and the CVE record today. HashiCorp scored it 9.9 (Critical) under CVSS 3.1, with the same vector we sent in the report.

  • Bulletin: HCSEC-2026-50
  • CVE: CVE-2026-110720, CVSS 3.1 9.9 (Critical), AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H, CWE-863
  • Affected: consul-k8s 0.42.0 through 2.0.4, when global.acls.manageSystemACLs=true
  • Fixed in: consul-k8s 2.1.0 and 1.8.18

How consul-k8s logs its components in

With manageSystemACLs enabled, a job called server-acl-init configures Consul’s ACL system during the install. One of the things it creates is a Kubernetes auth method, <release>-k8s-component-auth-method, which lets a pod trade its ServiceAccount token for a Consul token. Consul validates the token through the Kubernetes TokenReview API and then hands three attributes to its binding rules: serviceaccount.namespace, serviceaccount.name and serviceaccount.uid. A binding rule is a selector over those attributes, plus the role to grant when the selector matches.

For every component, server-acl-init writes a policy, a role and a binding rule. This is the binding rule as it stood in 2.0.4, in control-plane/subcommand/server-acl-init/create_or_update.go:

abr := &api.ACLBindingRule{
    Description: fmt.Sprintf("Binding Rule for %s", serviceAccountName),
    AuthMethod:  authMethodName,
    Selector:    fmt.Sprintf("serviceaccount.name==%q", serviceAccountName),
    BindType:    api.BindingRuleBindTypeRole,
    BindName:    aclRoleName,
}

The selector checks one attribute out of the three. That line reads the same in 0.42.0, in 1.9.12 and in 2.0.4.

What made this look like an oversight and not a design decision is that the same repository already did it correctly elsewhere. The API gateway code in control-plane/api-gateway/cache/consul.go builds its binding rules with serviceaccount.name==%q and serviceaccount.namespace==%q. Only the component rules left the namespace out.

What the connect injector’s role can do

We went after the connect injector because it runs in every ACL-enabled install. Its policy comes from injectRules(), where a code comment explains why it holds acl = "write": the injector has to delete the tokens that pods create through consul login. The same policy grants operator = "write" and mesh = "write". The snapshot agent’s policy has acl = "write" as well.

In Consul, acl = "write" lets a token create new tokens and attach any policy to them, global-management included. With a management token in hand, an attacker can read the entire KV store (where plenty of teams keep application secrets), list every ACL token in the cluster, rewrite the service intentions that decide which services may talk to each other, and mint tokens for any service identity they like.

Who could exploit it

Two things were needed:

  • the right to create a ServiceAccount in some namespace and get a token for it, either with kubectl create token or by running a Pod that mounts it
  • network access to the Consul HTTP API, which pods inside the cluster usually have

A team that owns a namespace in a shared cluster has both by default, and so does an attacker who has taken over a workload with those rights. The issue only applies with global.acls.manageSystemACLs=true. With system ACL management off, consul-k8s doesn’t create these binding rules.

Reproduction

We ran this in September on a k0s v1.36.4 cluster, against consul-k8s 2.0.3 and main at commit f54cce7. We didn’t do a full Helm install. Instead we created by hand the objects server-acl-init would have created, taken from its source: the auth method from createAuthMethodTmpl, the connect-inject policy from injectRules, the ACL role, and the binding rule above. The auth method’s own ServiceAccount got exactly the RBAC from the chart’s auth-method-clusterrole.yaml.

That RBAC turned out to matter. During a login, the Consul leader reads the ServiceAccount object of whoever is logging in, and our first attempt failed at that step because the auth method’s account couldn’t read ServiceAccounts in other namespaces. The chart grants get serviceaccounts through a ClusterRole bound cluster-wide, and once we applied it, the lookup worked for a ServiceAccount in any namespace.

The attacker side never leaves their own namespace:

kubectl -n tenant-a create serviceaccount consul-connect-injector
kubectl -n tenant-a create token consul-connect-injector

The token belongs to tenant-a/consul-connect-injector. Logging in to Consul with it:

POST /v1/acl/login
{"AuthMethod": "consul-k8s-component-auth-method", "BearerToken": "<tenant-a token>"}

Consul token issued : 8e62de50...
Roles granted       : ['consul-connect-inject-acl-role']

Our lab names had no release prefix. On a chart install with the release name consul, the ServiceAccount to copy is consul-consul-connect-injector and the auth method is consul-consul-k8s-component-auth-method; the test steps in HashiCorp’s fix PR use those names and show the same cross-namespace login going through on a regular install.

From there, the role’s acl = "write" does the rest:

$ consul acl token create -policy-name=global-management -description='pwned by tenant-a'
  minted token : 7a1484d9...
  policies     : ['global-management']

$ consul acl token read -self
  self policies: ['global-management']

$ consul kv get secret/db-password
  hunter2

$ consul acl token list
  4 tokens visible

secret/db-password is a value we planted in the lab so there’d be something to read. For the control, a ServiceAccount in the same namespace with a different name (some-other-name) got HTTP 403 Permission denied from the same login endpoint. The selector was running and it did reject the wrong name. It just never asked which namespace the name came from.

The fix

Manisha Kumari fixed it for the 1.8 line in PR #5746, and binilvrgs brought the change to 2.1 in PR #5749. Both merged on 19 October. The selector now includes the namespace the release runs in:

-		Selector:    fmt.Sprintf("serviceaccount.name==%q", serviceAccountName),
+		Selector: fmt.Sprintf(
+			"serviceaccount.name==%q and serviceaccount.namespace==%q",
+			serviceAccountName,
+			c.flagK8sNamespace,
+		),

-k8s-namespace is now a required flag for server-acl-init; the chart already passed it. The PR goes further than our report did in two places. The terminating gateway set up its own binding rule with the same name-only selector, and now uses the release namespace too. In federated clusters, global component rules are written to the primary datacenter but used to be looked up in the secondary, so an upgrade would never have found them; the fix reads them from the primary and updates every matching rule, duplicates included.

Upgrading rewrites existing binding rules in place, so you don’t need to recreate anything by hand. There’s one exception, covered in the next section.

What to do

Upgrade

Move to consul-k8s 2.1.0 or 1.8.18. Those are the only fixed releases. HashiCorp didn’t ship the fix on the 1.9 or 2.0 lines, so clusters on either of those need 2.1.0. The release notes add one requirement: any component that runs outside the Consul release namespace has to move into it, because its binding rule now insists on that namespace.

Check the rules afterwards

The upgrade only rewrites rules for components that are currently enabled. If you turned a component on at some point (sync-catalog, say, or a mesh gateway) and later turned it off, its old name-only rule is still sitting in Consul. The release notes tell you to delete it, or to re-enable the component for one upgrade so the rule gets rewritten. To see what you have:

consul acl binding-rule list -method <release>-k8s-component-auth-method

Every selector in that list should contain serviceaccount.namespace==. Delete any that don’t:

consul acl binding-rule delete -id <id>

If the upgrade has to wait

You can add the namespace term to each rule yourself with consul acl binding-rule update -id <id> -selector '...'. Treat that as a stopgap. An unpatched server-acl-init finds its rules by role name and description and writes the name-only selector back every time it runs, so the next helm upgrade on the old chart will quietly undo your change.

Look for signs it was used

An attacker had to create a ServiceAccount with a component’s name, so a listing catches the obvious cases:

kubectl get serviceaccounts --all-namespaces | grep -E 'connect-injector|snapshot-agent|mesh-gateway|sync-catalog'

Anything outside your Consul namespace needs an explanation. A ServiceAccount can be deleted once it’s served its purpose, so if you keep Kubernetes audit logs, search them for ServiceAccount creations and token requests using those names as well. On the Consul side, check every token that carries global-management and make sure you know where each one came from.

Checking your own binding rules

In Kubernetes, a ServiceAccount’s identity is its namespace and its name together. The token itself says so: its subject is system:serviceaccount:<namespace>:<name>. A rule that checks only the name trusts half of an identity, and the half it trusts is the part any tenant gets to choose. If you’ve written your own binding rules for Consul’s Kubernetes auth method, for your services or for an operator, it’s worth spending five minutes checking that each selector includes serviceaccount.namespace.

Disclosure timeline

  • 7 September 2026: we reported the issue to HashiCorp security by email
  • 8 September 2026: HashiCorp acknowledged the report
  • 14 October 2026: CVE-2026-110720 reserved
  • 19 October 2026: fix merged in PR #5746 (1.8.x) and PR #5749 (2.1.x)
  • 20 October 2026: consul-k8s 1.8.18 released
  • 23 October 2026: consul-k8s 2.1.0 released
  • 26 October 2026: HCSEC-2026-50 and the CVE record published

Thanks to HashiCorp’s security team for handling the report and keeping us updated, and to Manisha Kumari and binilvrgs for the fix.

References

If you run shared Kubernetes clusters and want to know whether one tenant can reach another’s secrets, that’s the kind of thing our cloud security assessments test for.

Know what an attacker would reach first.

Tell us what you run and what worries you. We will come back with a scope, a fixed price and the earliest week we can start.