Storm-3068: A CI/CD Pipeline Is a Kubeconfig Exfiltration Machine
Microsoft's Storm-3068 report used zero malware to steal Kubernetes credentials, just a password reset, a pipeline edit, and permissions nobody had scoped down.
Key takeaways
Microsoft's Storm-3068 report used zero malware to steal Kubernetes credentials, just a password reset, a pipeline edit, and permissions nobody had scoped down.
On this page
Microsoft's write-up on the Storm-3068 intrusion is unsettling for one reason: none of it required malware. The attacker reset a password, registered their own MFA, then used an Azure DevOps pipeline exactly the way pipelines are supposed to work to pull kubeconfig files out of a cluster. If your CI/CD service connections can reach the whole cluster instead of one namespace, you are one phished or stuffed credential away from the same outcome.
The chain, as Microsoft reported it#
Microsoft's account starts with self-service password reset abuse: the attacker took over an Azure AD identity through SSPR, likely using recovery information obtained elsewhere, then registered their own authentication method and removed the legitimate user's MFA. That single move converts a one-time takeover into standing access: the attacker can re-authenticate at will, and the real owner has no MFA prompt to notice anything changed. From there, the attacker used the account's legitimate Azure DevOps access to enumerate repositories, projects, pipelines, and environments with ordinary tooling, no custom exploit code involved. They then modified a pipeline authorized to reach more than 50 resources, running jobs that retrieved kubeconfig files and committed them into a kubeconfigs directory. Microsoft reported seven such files recovered from that one repo.
Why this is a pipeline problem, not an identity problem alone#
It is tempting to read this as "patch the identity layer and move on." Phishing-resistant MFA matters, but the part that should bother infrastructure teams is what happened after the takeover: a pipeline with broad cluster access did exactly what it was told, by an account that looked legitimate. The attacker needed no Kubernetes vulnerability and no container escape, just a service connection scoped to more than it should have been and a pipeline job that could run kubectl config view and write the output somewhere.
That is the uncomfortable generalization: almost every CI/CD pipeline touching Kubernetes has this shape today. Service connections get created once, scoped to "the cluster" because that was easier than scoping to a namespace, and nobody revisits them until an incident forces the question. We covered the parallel problem on the GitHub Actions side in GitHub Actions secrets: a workflow with more scope than its job needs is a liability that sits quiet until someone with legitimate-looking credentials finds it.
Scope service connections to a namespace, not a cluster#
The fix is unglamorous: stop granting Azure DevOps service connections cluster-admin-equivalent kubeconfigs, and issue scoped Kubernetes service accounts instead. A pipeline that deploys to one namespace should hold a kubeconfig that can only touch that namespace.
# rbac/pipeline-deployer-role.yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: pipeline-deployer
namespace: payments
rules:
- apiGroups: ["apps", ""]
resources: ["deployments", "services", "configmaps"]
verbs: ["get", "list", "update", "patch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: pipeline-deployer-binding
namespace: payments
subjects:
- kind: ServiceAccount
name: ado-pipeline-deployer
namespace: payments
roleRef:
kind: Role
name: pipeline-deployer
apiGroup: rbac.authorization.k8s.io
A Role, not a ClusterRole, bound to a namespace-scoped service account means a stolen kubeconfig is worth one namespace's deployments, not the cluster. Repeat the pattern per environment rather than sharing one service connection across staging and production.
Make kubeconfigs short-lived instead of static#
A scoped kubeconfig that never expires is still a standing credential worth stealing. Pair namespace scoping with short-lived tokens so a stolen kubeconfig in a repo goes stale before anyone finds it.
$ kubectl create token ado-pipeline-deployer \
--namespace payments \
--duration 1h \
--kubeconfig /tmp/pipeline-kubeconfig.yaml
$ az pipelines variable-group variable update \
--group-id 42 --name KUBECONFIG_TOKEN \
--value "$(cat /tmp/pipeline-kubeconfig.yaml)" --secret true
Generate the token inside the pipeline run itself, inject it as a secret variable scoped to that job, and let it expire. Static kubeconfigs sitting in a variable group or a repository are exactly what Storm-3068 went looking for.
Correlate the signal Microsoft actually called out#
Microsoft's recommendation is specific: watch for anomalous SSPR activity and treat an immediate MFA method change right after a password reset as one correlated alert, not two unrelated log lines. Most tenants log both events separately, and neither triggers anything on its own. Build the detection as a single rule: password reset followed within a short window by new MFA registration and an old-method removal on the same identity. Pair that with Azure DevOps and Git audit logs correlated against Kubernetes API server audit logs, so a pipeline suddenly reading kubeconfig secrets shows up next to the identity event that enabled it, not three dashboards away.
Audit what pipelines are authorized to touch, now#
Before the next incident forces it, pull every service connection in your Azure DevOps organization and list what each one is scoped to. Microsoft's report noted the compromised pipeline was authorized against more than 50 resources, a number that reads like scope accumulated over years rather than scope anyone deliberately granted. The audit is an afternoon of work: list service connections, list the RBAC bound to each one's service account, and cut anything broader than the pipeline's actual deploy target. Our Kubernetes security best practices checklist covers RBAC least privilege in more depth if you are starting from zero.
The decision, concretely#
- Does a pipeline's service connection need to reach more than one namespace? If not, scope the Kubernetes RBAC to a
Role, not aClusterRole, and issue a namespace-bound service account. - Is the kubeconfig a static, long-lived credential sitting in a variable group? Replace it with a short-lived token minted at job runtime, scoped to that run.
- Does your identity monitoring treat an SSPR event and an MFA registration change as separate, unrelated alerts? Correlate them into one rule; that sequence is the exact persistence move Microsoft described.
- Have you listed what every Azure DevOps service connection is actually authorized to touch in the last year? If the answer is "nobody's checked," that is this quarter's security backlog item, not next year's.
The call we'd make#
Treat every pipeline with Kubernetes access as a kubeconfig-exfiltration risk by default, because Storm-3068 proved no malware is required to turn one into exactly that. Scope service connections to a namespace, make the kubeconfigs short-lived, and wire the SSPR-plus-MFA-change correlation into detection this month. None of this is exotic engineering; it is the least-privilege and log-correlation work most teams keep deferring until an incident makes it mandatory. Attacks using only legitimate tooling are the ones malware-focused detection misses entirely, which is why this one deserves acting on now. Our notes on AI agent security cover the same scoped-credential discipline for agentic tooling with pipeline access.
Get the DevOps Troubleshooting Cheat Sheet
Subscribe and get our free one-page reference for the errors that eat an afternoon — CrashLoopBackOff, OOMKilled, Terraform state locks, and more — plus new guides as we publish them.
Docker Cloud Sandboxes: Why Agents Need MicroVMs, Not Containers
Docker put AI coding agents in hosted microVMs instead of containers, because a container was never the isolation boundary this job needed.
Claude Sonnet 5.5: Should You Switch From Sonnet 5
Same sticker price, a real-world cost drop from speed and token efficiency. Here is who should move today and who can wait a sprint.
More from DevOps
Explore more articles in this category
GitLab's New Rate Limits: What to Fix Before Oct 19
GitLab is capping unauthenticated API calls at 60 an hour starting October 19, and the preview windows land before most teams will have noticed.
Docker Cloud Sandboxes: Why Agents Need MicroVMs, Not Containers
Docker put AI coding agents in hosted microVMs instead of containers, because a container was never the isolation boundary this job needed.
Zed Delta and the Pull Request: Obsolete, or Just Overloaded?
Zed says agents made pull requests obsolete and shipped Delta to replace them. The review unit needs rethinking, but review itself, and the gates around it, stay.
You might have missed
Evergreen posts worth revisiting.