Security Research Jul 22, 2026

The Name Tag That Said 'Admin': My CVE Story

A small story about CVE-2026-61549, a privilege escalation in Woodpecker CI's Kubernetes backend, where a pipeline could hand its Pod any identity it liked — and nobody checked the name tag.

Some Bugs Break Things. This One Just Politely Asked for a Promotion.

Some bugs kick the door in. They crash the process, spill errors everywhere, and generally make a scene.

This one did the opposite.

This one filled in a form, wrote a name on it, and was quietly handed the keys.

Nobody’s alarm went off. Nothing crashed. The system did exactly what it was told — that was the whole problem.

That polite little request eventually became CVE-2026-61549.

Where the Story Begins

The project was Woodpecker CI, an open-source, community-run CI/CD system. It started life as a friendly fork of Drone.

If you have never used a CI system, here is the one-line version: you put a file called .woodpecker.yaml in your repository, and every time you push code, Woodpecker reads that file and runs the steps you listed — build, test, deploy, whatever.

It pairs with a “forge” — Gitea, Forgejo, GitHub, GitLab — which is just the place your code lives and the thing that says, “hey, someone pushed.”

This particular story only happens in one specific setup: when Woodpecker is running on the Kubernetes backend.

On that backend, every step in your pipeline runs as its own Pod — a small, temporary container that Kubernetes spins up, runs your commands in, and then throws away.

And this is where we need to talk about name tags.

The Building With the Sticker Badges

Imagine an office building where everyone wears one of those stick-on badges from the front desk.

“HELLO my name is ______.”

You write your own name in the blank, with the pen sitting on the counter.

Now imagine the building doesn’t just read the badge for your name. It reads it for who you are. Write “Visitor,” and the visitor doors open. Write “Facilities,” and you can reach the boiler room. Write “Head of Security,” and — well, everything opens.

In Kubernetes, that badge is called a ServiceAccount.

A Pod’s ServiceAccount is its identity. It decides what the Pod is allowed to do — which secrets it can read, which parts of the cluster it can touch — and a token proving that identity gets quietly slipped inside the Pod when it starts.

Whatever name is on the badge, the Pod inherits every power that comes with it.

So the badge is not a decoration. It is the entire question of “who is this, and what are they allowed to do?”

You would want someone checking those, right?

Five Guarded Doors

Here is the part I genuinely admired about Woodpecker before I found the problem.

The Kubernetes backend lets a pipeline set several sensitive Pod options straight from the step YAML. And the maintainers clearly knew some of those were dangerous — because they put a lock on them.

The lock was an admin switch. For each risky option, the administrator had to flip an AllowFromStep gate on the server before a pipeline was even allowed to set it.

Five of them had this:

  • Pod Labels — WOODPECKER_BACKEND_K8S_POD_LABELS_ALLOW_FROM_STEP
  • Pod Annotations — WOODPECKER_BACKEND_K8S_POD_ANNOTATIONS_ALLOW_FROM_STEP
  • Pod Tolerations — WOODPECKER_BACKEND_K8S_POD_TOLERATIONS_ALLOW_FROM_STEP
  • Pod Affinity — WOODPECKER_BACKEND_K8S_POD_AFFINITY_ALLOW_FROM_STEP
  • Native Secrets — WOODPECKER_BACKEND_K8S_ALLOW_NATIVE_SECRETS

Five doors, each with a deadbolt, each opened only by the admin.

The pattern was right there. Someone had thought carefully about this.

The Door With No Lock

Then I looked at serviceAccountName.

The badge. The identity. The single most powerful thing on the list.

And it had no lock at all.

The pipeline YAML could set backend_options.kubernetes.serviceAccountName to anything, and Woodpecker took the value straight from the user and stamped it onto the Pod.

In the code, the struct field in pipeline/backend/kubernetes/backend_options.go just… accepted it:

ServiceAccountName string `mapstructure:"serviceAccountName"` // no gating

And a little further along, in pipeline/backend/kubernetes/pod.go, at the point where the Pod is actually built, it went right onto the spec:

spec := kube_core_v1.PodSpec{
    ServiceAccountName: options.ServiceAccountName, // straight from user YAML
}

No allow-list. No validation. No admin gate. The pipeline linter never even looked inside backend_options.

So the five siblings sat locked behind an admin switch, and the most dangerous one of the family strolled around with its door wide open.

(runtimeClassName was in the same unlocked state, for what it’s worth.)

That is the whole bug. Not a memory corruption. Not a clever exploit chain. Just one field that everyone assumed someone else was guarding.

What an Attacker Actually Does

You do not need to be an admin. You do not need a stolen token. You need one thing: Push permission on a repository connected to the instance — the ability to edit the pipeline file.

In open-source and multi-tenant setups, that is a low bar. Push access is normal. It is supposed to be safe.

The attack is boringly short:

steps:
  - name: privesc
    image: bitnami/kubectl:latest
    backend_options:
      kubernetes:
        serviceAccountName: some-privileged-sa   # e.g. an admin service account in the namespace
    commands:
      - kubectl auth can-i --list
      - kubectl get secrets -A

Push that, the pipeline runs, and the Pod comes up wearing the identity you named.

Its permissions are your permissions. Its mounted token is your token.

kubectl get secrets -A is not a movie hacking scene with dramatic music. It is a Pod politely asking the cluster for the secrets, and the cluster — reading the badge — politely handing them over.

Why It Was Serious

If a privileged ServiceAccount exists anywhere in the pipeline’s namespace, and in real clusters one almost always does, this is a clean privilege escalation.

From there the road is short: read Kubernetes Secrets (database credentials, API keys, TLS certs), deploy your own workloads, move sideways into other namespaces, reach the cloud metadata endpoint, and in the worst case take over the whole cluster.

Formally, the advisory rated it High severity, with a CVSS score of 7.5.

It was classified under CWE-269 (Improper Privilege Management) and CWE-862 (Missing Authorization) — a privilege escalation bug, reachable over the network, low complexity, no user interaction.

In plain English: a user who could only write a config file got to decide who their code ran as.

Responsible Disclosure

After confirming the behaviour, I reached out to the Woodpecker maintainers directly and walked them through it.

They were quick and gracious about it. They confirmed the issue, wrote the fix, and then published the GitHub Security Advisory themselves on July 1, 2026 as GHSA-qf34-295c-26v8, assigned CVE-2026-61549.

Affected: all versions of Woodpecker before 3.16.0 — the range >= 1.0.0, < 3.16.0 — using the Kubernetes backend.

Patched in: 3.16.0.

The fix landed in pull request #6792, and it did the obvious, correct thing: it gave serviceAccountName (and runtimeClassName) the same admin gate the other five options already had. The badge counter finally has someone standing behind it.

I was the sole finder credited on this one, and I will be honest — seeing my name on the advisory felt good. Not because something broke, but because a real project used by real teams is a little safer now.

If you cannot upgrade yet, the advisory suggests sensible stopgaps: restrict Push access to trusted users, give each org its own isolated namespace, disable ServiceAccount token automounting, and pin the allowed ServiceAccount with an admission policy like OPA/Gatekeeper or Kyverno.

The Real Lesson

I keep coming back to the same theme in these stories, and here it is again.

Many bugs live in the gap between two systems.

Woodpecker looked at serviceAccountName and saw a harmless configuration string — one more field to copy onto a spec.

Kubernetes looked at the exact same value and saw an identity, with real powers attached to it.

Two systems, two readings of one word, and the gap between them was the whole vulnerability.

The tell was not some genius insight. It was noticing an inconsistency. Five options were guarded. One sibling standing right next to them was not.

So I asked one simple question:

“Why does this field get to skip the lock the others have?”

There was no good answer. That is usually how you know.

Final Thought

CVE-2026-61549 was not about breaking Kubernetes or defeating clever cryptography.

It was about a name tag that nobody checked.

The pipeline wrote “admin” in the blank, and the cluster said, “Right this way.”

Security is often less about the loud, cinematic exploit and more about walking down a hallway of doors, noticing that five of them are locked, and quietly wondering out loud about the sixth.

Sometimes that quiet question is worth a patch.

And sometimes it is worth a CVE number.