21 minute read

Mình đã kiếm ra 3 CVE trong 1 ngày như thế nào ?

Tác giả: Trần Hoàng Phúc Quân — Viettel Digital Talent 2026
Advisories: GHSA-f94q-w3w8-cj67 · GHSA-gxjc-74v5-3vx3 · GHSA-68cj-mvg9-rgm2

I. Lời mở đầu

Trong quá trình nghiên cứu bảo mật Kubernetes cho chương trình Viettel Digital Talent 2026, tôi thực hiện phân tích mã nguồn của Capsule — một Kubernetes multi-tenancy operator mã nguồn mở được sử dụng rộng rãi trong môi trường enterprise. Quá trình source-sink analysis và variant analysis dẫn đến việc phát hiện 3 lỗ hổng bảo mật, trong đó 2 lỗ hổng đã được publish và 1 đang trong quá trình review.

https://github.com/projectcapsule/capsule/security/advisories/GHSA-f94q-w3w8-cj67 https://github.com/projectcapsule/capsule/security/advisories/GHSA-gxjc-74v5-3vx3

Cùng với bản vá của vendor https://github.com/projectcapsule/capsule/commit/8d89d6865df6f41c7faa22fc9e807a57b01bfd0e

Advisory Mô tả CVSS Status CWE
GHSA-f94q-w3w8-cj67 hostname_regex.go — validates stale Tenant 6.8 Published ✅ CWE-697
GHSA-gxjc-74v5-3vx3 forbidden_annotations — wrong field validated 6.8 Published ✅ CWE-20
GHSA-68cj-mvg9-rgm2 CapsuleConfig NodeMetadata — no validator ~6.8 Triage ⏳ CWE-20

II. Tìm hiểu Target — Capsule

1. Capsule là gì?

Capsule là một framework mã nguồn mở được phát triển dành riêng cho hệ sinh thái Kubernetes. Dự án này đã được chấp thuận vào mức độ Sandbox của CNCF (Cloud Native Computing Foundation) từ tháng 12 năm 2022.

Capsule giải quyết bài toán cluster sprawl trong môi trường enterprise: thay vì tạo một Kubernetes cluster riêng cho mỗi team (tốn kém, khó quản lý), Capsule gộm nhiều namespace thành một abstraction gọi là Tenant. Nhiều team dùng chung một cluster nhưng được cô lập hoàn toàn về RBAC, NetworkPolicy, ResourceQuota, LimitRange.

2. Kiến trúc của hệ thống

Capsule có 4 thành phần cốt lõi:

  • Tenant CRD — bản thiết kế của một tenant. Cluster Admin tạo Tenant object với đầy đủ chính sách: quota, RBAC, allowed hostname/storageclass/registry, forbidden labels/annotations.
  • Tenant Controller — reconcile loop, tự động tạo và đồng bộ các object namespace-level (NetworkPolicy, LimitRange, ResourceQuota, RoleBinding) theo spec của Tenant.
  • Policy Engine (Admission Webhook Server) — ValidatingWebhookConfiguration + MutatingWebhookConfiguration. Đây là thành phần trung tâm: mọi admission request đều đi qua đây trước khi vào etcd.
  • capsule-proxy — reverse proxy lọc response để tenant user chỉ nhìn thấy resource của chính mình khi dùng kubectl.

3. Tại sao mình nghĩ webhook là attack surface quan trọng nhất?

Capsule là lớp bảo mật giữa tenant user và cluster. Webhook là cửa kiểm soát duy nhất — nếu có lỗ hổng:

  • Validation bị bypass → dữ liệu độc hại vào được etcd, ảnh hưởng dài hạn.
  • Sai logic check → crash admission webhook → Denial of Service.
  • Cross-boundary impact → một admin có thể phá hoại toàn bộ cluster thông qua một field duy nhất.

III. Phương pháp phân tích

1. Đọc các CVE đã có của Capsule trước

Trước khi đụng vào một dòng code nào, mình đọc 2 CVE trước của project để hiểu Capsule hay bị lỗi ở đâu:

  • CVE-2024-39690: tenant owner có quyền patch namespace có thể chiếm quyền kiểm soát system namespaces. Root cause: namespace ownership check bị bypass khi user patch label trực tiếp mà không qua đúng guard.
  • CVE-2025-55205: label injection vào system namespaces (kube-system, default) vì điều kiện check chỉ validate tenant ownership khi namespace đã có tenant label — nếu không có label thì bỏ qua luôn.

Cả hai CVE đều có chung một đặc điểm: validator tồn tại, chạy đúng chỗ, nhưng check sai điều kiện. Không phải thiếu validation hoàn toàn, mà là validation bị lách qua vì logic sai ở edge case. Đây là dạng bug khó phát hiện nhất vì code trông có vẻ đúng khi đọc lướt.

Từ đó mình rút ra mental model: attack surface chính của Capsule là admission webhook handlers — nơi dữ liệu đi vào etcd. Pattern cần tìm là validator có nhưng check không đúng thứ, hoặc dữ liệu người dùng kiểm soát được tiếp cận API nguy hiểm như regexp.MustCompile (panic nếu regex lỗi) hay selector matching (có thể match ngoài ý muốn).

2. Viết script source-sink analysis

Thay vì đọc từng file thủ công — Capsule có hàng trăm file Go — mình viết hai script để tự động phân loại codebase theo trust boundary pattern. Ý tưởng xuất phát từ cách hai CVE trước được công bố: mỗi bug đều có “source” (nơi dữ liệu user đi vào) và “sink” (nơi dữ liệu đó được sử dụng nguy hiểm).

capsule_trace.py định nghĩa 6 category dựa trên pattern từ các CVE đã biết:

Category Mô tả Ví dụ pattern tìm kiếm
namespace-ownership Ai sở hữu namespace này? ResolveNamespaceTenant, HasTenantOwnership
namespace-metadata Ghi/đọc label, annotation SetLabels, validateUserMetadata, ForbiddenLabels
selector-fanout Query nhiều namespace cùng lúc GetMatchingNamespaces, LabelSelectorAsSelector
status-authz Dùng Status field để authorize IsTenantOwnerByStatus, .Status.Namespaces
ownerreference Mutation ownerRef SetOwnerReference, HasOwnerReference
conditional-bypass Điều kiện allow/deny có thể bị lách if ... == nil { return nil }, IsAdmin()

quick_audit.py tập trung vào các pattern rủi ro cao hơn: regexp.MustCompile (panic nếu nhận regex lỗi từ user), ignored errors dạng _ = c.Get(...) (fail-open khi API server tạm thời không phản hồi), và các nhánh admin bypass có thể bị exploit.

Sau khi chạy cả hai script trên toàn bộ repo, kết quả:

namespace-ownership:  559 hits   (nhiều nhất — core logic của Capsule)
selector-fanout:      254 hits
status-authz:         244 hits
namespace-metadata:   172 hits
ownerreference:        44 hits
conditional-bypass:     9 hits   ← ÍT NHẤT

Quyết định đọc conditional-bypass trước vì 9 hits là ít nhất, signal-to-noise ratio tốt nhất. Hai CVE trước của Capsule đều có dạng “điều kiện check bị bypass”, nên đây là category khớp nhất với pattern đã biết. Đọc qua 9 hits, mình thấy một điểm thú vị trong handler.go — hàm rejectOnTermination ignore error của c.Get():

_ = c.Get(ctx, types.NamespacedName{Name: t.GetName()}, tnt)
if tnt.DeletionTimestamp == nil {
    return nil  // ← ALLOW nếu Get() fail, tnt là zero-value Tenant{}
}

Đây là fail-open behavior: nếu API server tạm thời không phản hồi, tnt vẫn là &Tenant{} rỗng, DeletionTimestamp == nil luôn true, request được phép qua. Không đủ để thành CVE độc lập nhưng xác nhận một điều: codebase này có những chỗ không xử lý error đúng cách. Mình tiếp tục đào sâu hơn.

3. Từ script output đến bug thực sự — tìm outlier

quick_audit.py flag các điểm dùng regexp.MustCompile với input có thể đến từ user. Điều này hướng mình sang nhìn vào các validator webhook — cụ thể là folder internal/webhook/tenant/validation/ với 12 file handler, mỗi file validate một field khác nhau trong Tenant spec (hostname regex, storageclass, ingressclass, container registry, forbidden labels/annotations…).

Đọc từng file một thì mất quá nhiều thời gian. Mình grep để so sánh structure của tất cả OnUpdate handler cùng lúc. Cụ thể là so sánh thứ tự tham số *capsulev1beta2.Tenant trong hàm OnUpdate() — vì interface quy định rõ tham số thứ 3 là Tenant MỚI, tham số thứ 4 là Tenant CŨ:

bash

for f in internal/webhook/tenant/validation/*.go; do
    name=$(basename $f)
    params=$(grep -A8 "func.*OnUpdate" "$f"         | grep "*capsulev1beta2.Tenant"         | awk '{print $1}'         | tr '
' ' ')
    echo "$name: $params"
done

Output đầy đủ:

containerregistry_regex.go:      tnt old   ✓
forbidden_annotations_regex.go:  tnt old   ✓
freezed_emitter.go:              tnt old   ✓
ingressclass_regex.go:           tnt old   ✓
namespace_metadata.go:           newTnt _  ✓
owners.go:                       tnt _     ✓
required_metdata_regex.go:       tnt old   ✓
rolebindings_regex.go:           tnt old   ✓
rule_validator.go:               tnt old   ✓
storageclass_regex.go:           tnt old   ✓
warnings.go:                     tnt old   ✓
hostname_regex.go:               old tnt   ← DUY NHẤT KHÁC!

11 file đều khai báo (tnt, old)tnt là Tenant mới, old là Tenant cũ. Chỉ hostname_regex.go khai báo ngược: (old, tnt).

Lúc này mình chưa kết luận ngay đây là bug. Có thể đây là convention khác, hoặc file này có logic đặc biệt. Mình đọc kỹ file đó, đọc interface definition trong handlers.go, đọc dispatcher trong handler.go:93 xem thứ tự gọi thực tế — và xác nhận: đây là argument swap thực sự. Webhook đang validate Tenant CŨ thay vì Tenant MỚI mỗi khi admin update. Đây là CVE 1.

Variant analysis — nhân bug lên thành 3

Sau khi xác nhận CVE 1, mình không dừng lại. Câu hỏi tự nhiên tiếp theo: “Nếu một developer swap argument ở hostname_regex.go, liệu có pattern sai tương tự ở các handler khác không?”

Đây gọi là variant analysis — approach được Google Project Zero và GitHub Security Lab dùng rộng rãi. Thay vì tìm kiếm ngẫu nhiên, variant analysis hỏi: “bug này thuộc loại nào? còn chỗ nào có cùng loại không?”

Mình đọc tiếp từng file trong internal/webhook/tenant/validation/. Đến forbidden_annotations_regex.go, mình thấy một vòng lặp kỳ lạ:

// Map với 2 entry: labels và annotations
annotationsToCheck := map[string]string{
    "labels":      tnt.Spec.NamespaceOptions.ForbiddenLabels.Regex,
    "annotations": tnt.Spec.NamespaceOptions.ForbiddenAnnotations.Regex,
}

// Nhưng cả 2 lần compile đều dùng ForbiddenLabels.Regex!
for scope, annotation := range annotationsToCheck {
    if _, err := regexp.Compile(
        tnt.Spec.NamespaceOptions.ForbiddenLabels.Regex  // ← không đổi
    ); err != nil {
        return ad.Denyf(...)
    }
}

Lỗi copy-paste: developer tạo map đúng với 2 entry riêng biệt, nhưng bên trong vòng lặp lại hardcode ForbiddenLabels.Regex thay vì dùng biến iteration. Kết quả là ForbiddenAnnotations.Regex không bao giờ được validate. Đây là CVE 2 — và nguy hiểm hơn CVE 1 vì downstream dùng regexp.MustCompile thay vì regexp.Compile, gây panic thay vì chỉ return error.

Tiếp tục: “Còn chỗ nào không có validator hoàn toàn không?” → kiểm tra internal/webhook/cfg/ (webhook cho CapsuleConfiguration):

internal/webhook/cfg/
├── handler.go
├── owners.go
├── serviceaccount.go
└── warnings.go

Không có bất kỳ file *_regex.go nào. Trong khi đó CapsuleConfiguration có các field như NodeMetadata.ForbiddenLabels.RegexForbiddenAnnotations.Regex — và chúng cũng đi qua regexp.MustCompile ở downstream. Đây là CVE 3.

Ba bug, ba biểu hiện khác nhau, từ cùng một root cause:

CVE 1: validator có, check sai object   → argument swap
CVE 2: validator có, check sai field    → copy-paste thiếu sửa biến
CVE 3: validator không tồn tại          → missing file hoàn toàn

4. CVE 1 — GHSA-f94q-w3w8-cj67

File: internal/webhook/tenant/validation/hostname_regex.go
CVSS: 6.8 / Moderate — CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:C/C:N/I:N/A:H
CWE: CWE-697 — Incorrect Comparison
Affected: v0.13.0 – v0.13.6 | Patched: v0.13.7

Root cause — Argument swap

Interface TypedHandler[T] định nghĩa OnUpdate với thứ tự tham số rõ ràng:

// pkg/runtime/handlers/handlers.go
OnUpdate(c client.Client, reader client.Reader,
    obj T,   // ← tham số thứ 3 = Tenant MỚI
    old T,   // ← tham số thứ 4 = Tenant CŨ
    decoder admission.Decoder, recorder events.EventRecorder) Func

Dispatcher trong handler.go:93 gọi đúng thứ tự:

hndl.OnUpdate(c, reader, tnt, old, decoder, recorder)
//                        ^^^  ^^^
//                        NEW  OLD

Nhưng hostnameRegexHandler.OnUpdate khai báo tên biến ngược lại:

// hostname_regex.go — BUG
func (h *hostnameRegexHandler) OnUpdate(
    _ client.Client,
    _ client.Reader,
    old *capsulev1beta2.Tenant,   // ← nhận NEW (tên biến đặt sai là 'old')
    tnt *capsulev1beta2.Tenant,   // ← nhận OLD (tên biến đặt sai là 'tnt')
    ...
) handlers.Func {
    return func(_ context.Context, req admission.Request) *admission.Response {
        if err := h.validate(tnt, req); err != nil { // validate Tenant CŨ!
            return err
        }
        return nil
    }
}

Tất cả 11 handler khác trong cùng package đều khai báo (tnt, old) đúng. hostname_regex.go là file duy nhất bị swap.

Attack chain

  1. Cluster Admin gửi: kubectl patch tenant team-a --type merge -p '{"spec":{"ingressOptions":{"allowedHostnames":{"allowedRegex":"[invalid("}}}}'
  2. Webhook validate tnt = Tenant CŨ (regex hợp lệ) → ALLOW
  3. Regex lỗi [invalid( được ghi vào etcd
  4. Developer tạo Ingress → validate_hostnames.go:160 chạy:

     matched, _ = regexp.MatchString("[invalid(", hostname)// error bị ignore, matched = false → hostname bị reject
    
  5. Toàn bộ Ingress CREATE/UPDATE trong tenant bị block — service không expose được ra ngoài

Tại sao Scope: Changed → CVSS 6.8? Vì tác động xảy ra với người dùng khác (developer trong tenant), không phải chính người tấn công (admin). Đây là cross-boundary impact.

Proof of Concept

package main

import ("fmt"; "regexp")

type Tenant struct{ AllowedHostnamesRegex string }

// Dispatcher gọi: OnUpdate(c, reader, NEW, OLD, ...)
// Bug: handler nhận (old=NEW, tnt=OLD) → validate(tnt) = validate Tenant CŨ
func buggyOnUpdate(newTenant, oldTenant *Tenant) error {
    tnt := oldTenant // ← nhận OLD
    _, err := regexp.Compile(tnt.AllowedHostnamesRegex)
    return err
}

func correctOnUpdate(newTenant, oldTenant *Tenant) error {
    _, err := regexp.Compile(newTenant.AllowedHostnamesRegex)
    return err
}

func main() {
    old := &Tenant{AllowedHostnamesRegex: `^[\w.-]+\.example\.com$`} // valid
    new := &Tenant{AllowedHostnamesRegex: `[invalid-regex(`}          // INVALID

    err := buggyOnUpdate(new, old)
    fmt.Printf("[BUGGY]   err=%v → webhook %s\n", err,
        map[bool]string{true: "DENY", false: "ALLOW (lỗi!)"}[err != nil])

    err = correctOnUpdate(new, old)
    fmt.Printf("[CORRECT] err=%v → webhook %s\n", err,
        map[bool]string{true: "DENY (expected)", false: "ALLOW"}[err != nil])

    // Downstream sau khi regex lỗi vào etcd
    matched, _ := regexp.MatchString(`[invalid-regex(`, "app.example.com")
    fmt.Printf("Ingress hostname allowed: %v (false = mọi Ingress bị block)\n", matched)
}

Output:

[BUGGY]   err=<nil> → webhook ALLOW (lỗi!)
[CORRECT] err=error parsing regexp... → webhook DENY (expected)
Ingress hostname allowed: false (false = mọi Ingress bị block)

Fix

// BEFORE — bug
func (h *hostnameRegexHandler) OnUpdate(
    _ client.Client, _ client.Reader,
    old *capsulev1beta2.Tenant,   // nhận NEW
    tnt *capsulev1beta2.Tenant,   // nhận OLD

// AFTER — fix
func (h *hostnameRegexHandler) OnUpdate(
    _ client.Client, _ client.Reader,
    tnt *capsulev1beta2.Tenant,   // nhận NEW ← đúng interface
    old *capsulev1beta2.Tenant,   // nhận OLD

5. CVE 2 — GHSA-gxjc-74v5-3vx3

File: internal/webhook/tenant/validation/forbidden_annotations_regex.go
CVSS: 6.8 / Moderate — CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:C:N/I:N/A:H
CWE: CWE-20 — Improper Input Validation
Affected: v0.13.0 – v0.13.6 | Patched: v0.13.7

Root cause — Wrong field trong validation loop

Capsule cho phép cấu hình labels và annotations bị cấm trên namespace qua Tenant.Spec.NamespaceOptions. Cả ForbiddenLabels lẫn ForbiddenAnnotations đều có field Regex.

Hàm validate() trong forbidden_annotations_regex.go tạo map 2 entry rồi iterate:

// forbidden_annotations_regex.go — BUG
annotationsToCheck := map[string]string{
    "labels":      tnt.Spec.NamespaceOptions.ForbiddenLabels.Regex,
    "annotations": tnt.Spec.NamespaceOptions.ForbiddenAnnotations.Regex,
}

for scope, annotation := range annotationsToCheck {
    if _, err := regexp.Compile(
        tnt.Spec.NamespaceOptions.ForbiddenLabels.Regex  // ← LABELS mãi mãi!
        // ForbiddenAnnotations.Regex không bao giờ được validate
    ); err != nil {
        return ad.Denyf("unable to compile %s regex for forbidden %s", annotation, scope)
    }
}

Cả hai lần iteration đều compile ForbiddenLabels.Regex. ForbiddenAnnotations.Regex không bao giờ được kiểm tra — lỗi copy-paste thiếu sửa tên biến bên trong lambda.

Tại sao nguy hiểm hơn CVE 1?

CVE 1 dẫn đến ingress bị block — impact silent, không crash. CVE 2 dẫn đến panic thực sự vì downstream dùng regexp.MustCompile:

// pkg/api/forbidden_list.go:34
func (in ForbiddenListSpec) RegexMatch(value string) (ok bool) {
    if len(in.Regex) > 0 {
        ok = regexp.MustCompile(in.Regex).MatchString(value)
        // ^^^^^^^^^^^^ PANIC nếu Regex không hợp lệ!
        // regexp.Compile trả về error
        // regexp.MustCompile gọi panic()
    }
    return ok
}

Khi namespace admission chạy và gọi RegexMatch với regex lỗi đã lưu trong etcd → namespace webhook crash → toàn bộ namespace operation của tenant bị sập.

Attack chain

  1. Admin update Tenant: ForbiddenLabels.Regex = hợp lệ, ForbiddenAnnotations.Regex = [invalid-(
  2. Webhook: vòng lặp compile ForbiddenLabels.Regex (hợp lệ) cho cả 2 iteration → ALLOW
  3. ForbiddenAnnotations.Regex lỗi được lưu vào etcd
  4. User tạo/update Namespace → namespace admission gọi RegexMatch với regex lỗi → regexp.MustCompile PANIC 💥
  5. Không tạo được namespace mới, không update được namespace cũ — DoS nặng hơn CVE 1

Proof of Concept

package main

import ("fmt"; "regexp")

type ForbiddenListSpec struct{ Regex string }

// Chép chính xác từ pkg/api/forbidden_list.go:34-38
func (in ForbiddenListSpec) RegexMatch(value string) bool {
    if len(in.Regex) > 0 {
        return regexp.MustCompile(in.Regex).MatchString(value)
    }
    return false
}

func main() {
    // Validator passes vì luôn check ForbiddenLabels.Regex (hợp lệ)
    fmt.Println("Webhook: ALLOW (validates wrong field)")

    // Sau khi ForbiddenAnnotations.Regex lỗi vào etcd:
    forbidden := ForbiddenListSpec{Regex: `[invalid-regex(`}

    defer func() {
        if r := recover(); r != nil {
            fmt.Printf("PANIC (namespace webhook sập): %v\n", r)
        }
    }()

    // Được gọi mỗi khi có namespace admission request
    forbidden.RegexMatch("some-annotation-key")
}

Output:

Webhook: ALLOW (validates wrong field)
PANIC (namespace webhook sập): regexp: Compile(`[invalid-regex(`): error parsing regexp: missing closing ]: `[invalid-regex(`

Fix

// BEFORE — bug: luôn compile ForbiddenLabels.Regex
for scope, annotation := range annotationsToCheck {
    regexp.Compile(tnt.Spec.NamespaceOptions.ForbiddenLabels.Regex)
}

// AFTER — fix: dùng đúng biến đang iterate
for scope, regex := range annotationsToCheck {
    regexp.Compile(regex)  // ← sử dụng giá trị từ map, không hardcode field
}

6. CVE 3 — GHSA-68cj-mvg9-rgm2 (Triage)

File: internal/webhook/node/user_metadata.go + pkg/api/forbidden_list.go
CVSS ước tính: ~6.8 / Moderate — CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:C/C:N/I:N/A:H
CWE: CWE-20 — Improper Input Validation
Affected: v0.13.0 – v0.13.7 (K8s >= 1.21) | Status: Đang Triage ⏳

Bối cảnh: node webhook enabled theo mặc định

Điểm quan trọng cần phân biệt giữa Helm chart toggle (hooks.nodes) và logic thực tế trong code:

// pkg/utils/kubernetes_version.go
var versionsWithNodeFix = []string{"v1.18.18", "v1.19.10", "v1.20.6", "v1.21.0"}

func NodeWebhookSupported(currentVersion *version.Version) (bool, error) {
    // Trả về FALSE (disable) CHỈ khi K8s < 1.18.18/1.19.10/1.20.6/1.21.0
    // Mục đích: tránh CVE-2021-25735 trên các cluster cũ
    // K8s >= 1.21.0 (mọi cluster hiện đại) → luôn trả về TRUE
    ...
    return true, nil  // ← enabled by default trên K8s >= 1.21
}
// internal/webhook/node/user_metadata.go
func (r *userMetadataHandler) OnUpdate(...) handlers.Func {
    return func(ctx context.Context, req admission.Request) *admission.Response {
        nodeWebhookSupported, _ := caputils.NodeWebhookSupported(r.version)
        if !nodeWebhookSupported {
            return nil  // chỉ skip trên K8s cũ < 1.21
        }
        // ... tiếp tục xử lý trên mọi cluster hiện đại
    }
}

Node webhook ENABLED by default trên K8s >= 1.21.0. Mọi cluster hiện đại (2024–2026) đều chạy K8s >= 1.28, đồng nghĩa node webhook luôn hoạt động.

Root cause — Hoàn toàn không có webhook validator

So sánh giữa Tenant webhook và CapsuleConfiguration webhook:

  • internal/webhook/tenant/validation/ có đủ validator cho mọi regex field: hostname_regex.go, storageclass_regex.go, ingressclass_regex.go, containerregistry_regex.go, forbidden_annotations_regex.go
  • internal/webhook/cfg/ chỉ có: owners.go, serviceaccount.go, warnings.gokhông có bất kỳ file *_regex.go nào

Kết quả: CapsuleConfiguration.Spec.NodeMetadata.ForbiddenLabels.RegexForbiddenAnnotations.Regex không có webhook validation. Admin có thể set regex lỗi mà Kubernetes API Server sẽ chấp nhận và lưu vào etcd ngay.

Attack chain

  1. Admin chạy: kubectl patch capsuleconfiguration default --type merge -p '{"spec":{"nodeMetadata":{"forbiddenLabels":{"deniedRegex":"[invalid("}}}}' → API Server accept, không có webhook nào chặn
  2. Regex lỗi [invalid( vào etcd
  3. Bất kỳ ai patch node label (kubectl label node worker-1 ..., node autoscaler, cloud provider) → userMetadataHandler.OnUpdate chạy → gọi ForbiddenUserNodeLabels().RegexMatch(label)
  4. pkg/api/forbidden_list.go:36 chạy regexp.MustCompile("[invalid(")PANIC 💥 → node webhook crash
  5. Mọi node operation trong cluster bị sập — cluster-wide DoS, không giới hạn trong 1 tenant

Scope: Changed (~6.8) vì khác CVE 2: impact ở đây là cluster-wide (tất cả node), không giới hạn trong 1 tenant. Admin phá → cả cluster chịu.

Proof of Concept

package main

import ("fmt"; "regexp")

type ForbiddenListSpec struct{ Regex string }

func (in ForbiddenListSpec) RegexMatch(value string) bool {
    if len(in.Regex) > 0 {
        return regexp.MustCompile(in.Regex).MatchString(value)
    }
    return false
}

func cfgWebhookValidation(regex string) error {
    // internal/webhook/cfg/ không có bất kỳ regex validator nào
    return nil // luôn allow
}

func tenantWebhookValidation(regex string) error {
    _, err := regexp.Compile(regex) // có validator
    return err
}

func main() {
    invalidRegex := `[invalid-regex(`

    // Bước 1: CapsuleConfiguration webhook không chặn
    err := cfgWebhookValidation(invalidRegex)
    fmt.Printf("cfg webhook: %s\n",
        map[bool]string{true: "DENY", false: "ALLOW ← không có validator!"}[err != nil])

    // So sánh: Tenant webhook có validator
    err = tenantWebhookValidation(invalidRegex)
    fmt.Printf("tenant webhook: %s\n",
        map[bool]string{true: "DENY (expected)", false: "ALLOW"}[err != nil])

    // Bước 2: Sau khi regex lỗi vào etcd, node admission bị panic
    defer func() {
        if r := recover(); r != nil {
            fmt.Printf("PANIC (node webhook sập cluster-wide): %v\n", r)
        }
    }()
    forbidden := ForbiddenListSpec{Regex: invalidRegex}
    forbidden.RegexMatch("kubernetes.io/hostname")
}

Output:

cfg webhook: ALLOW ← không có validator!
tenant webhook: DENY (expected)
PANIC (node webhook sập cluster-wide): regexp: Compile(`[invalid-regex(`): error parsing regexp: missing closing ]: `[invalid-regex(`

Fix đề xuất

Thêm file node_metadata_regex.go trong internal/webhook/cfg/:

func (h *nodeMetadataRegexHandler) validate(cfg *capsulev1beta2.CapsuleConfiguration) *admission.Response {
    if cfg.Spec.NodeMetadata == nil {
        return nil
    }
    for field, regex := range map[string]string{
        "forbiddenLabels.deniedRegex":      cfg.Spec.NodeMetadata.ForbiddenLabels.Regex,
        "forbiddenAnnotations.deniedRegex": cfg.Spec.NodeMetadata.ForbiddenAnnotations.Regex,
    } {
        if len(regex) > 0 {
            if _, err := regexp.Compile(regex); err != nil {
                return ad.Denyf("unable to compile %s: %v", field, err)
            }
        }
    }
    return nil
}

IV. Một vài thứ mà mình học được

Ban đầu mình cũng có xu hướng mở từng file ra, đọc từ đầu đến cuối, cố hiểu từng dòng. Cách đó tốn thời gian mà không hiệu quả — vì codebase lớn, đọc hết không khả thi, và đọc tuyến tính không giúp mình nhìn ra điểm bất thường.

Cái thay đổi approach của mình là câu hỏi: “Nếu đây là bug, nó sẽ trông như thế nào so với phần còn lại của codebase?”

Từ câu hỏi đó mình chuyển sang tìm outlier: thay vì đọc từng handler một, grep để so sánh cấu trúc của tất cả handler cùng lúc. 11 file đều (tnt, old), chỉ 1 file (old, tnt) — không cần hiểu business logic, chỉ cần nhìn thấy cái duy nhất khác là đủ để nghi ngờ.

Cách này không phải lúc nào cũng hoạt động, nhưng với codebase có pattern lặp lại (như 12 webhook handler cùng implement một interface) thì nó rất mạnh.

Script tự viết cho target cụ thể > tool generic

Mình đã thử dùng semgrep với các rule có sẵn trước. Output trả về hàng nghìn kết quả, phần lớn là false positive, mình không biết bắt đầu từ đâu.

capsule_trace.pyquick_audit.py mình viết riêng cho Capsule, dựa trên pattern từ các CVE cũ của project. Kết quả ít hơn rất nhiều nhưng mỗi hit đều có lý do cụ thể để đọc. Signal-to-noise ratio tốt hơn hẳn.

Bài học: trước khi viết script, phải hiểu target đủ để biết mình đang tìm gì. Script là tool để scale up cái insight đã có, không phải để thay thế việc suy nghĩ.

Variant analysis — tư duy “còn cái nào tương tự không?”

Đây là thứ mình thấy có giá trị nhất trong cả quá trình. Sau khi tìm được CVE 1, câu hỏi tự nhiên là: “Nếu bug này tồn tại ở đây, pattern tương tự có ở chỗ khác không?”

Không phải copy-paste cùng một bug, mà là cùng một loại sai lầm nhưng biểu hiện khác:

  • CVE 1: validator check sai object (Tenant cũ thay vì mới)
  • CVE 2: validator check sai field (Labels thay vì Annotations)
  • CVE 3: validator không tồn tại cho object tương tự

Ba bug này liên quan với nhau không phải vì code giống nhau, mà vì cùng một kiểu thiếu cẩn thận ở những chỗ có cùng chức năng. Khi tìm được 1, mình có đủ context để tìm tiếp 2 và 3 nhanh hơn nhiều so với lần đầu.

PoC không cần phức tạp

Cả 3 PoC mình viết đều chỉ dùng stdlib Go, không cần dựng cluster Kubernetes, không cần cài Capsule thật. Mỗi cái chưa đến 50 dòng, chạy bằng go run là xong.

Điều này không phải vì mình lười, mà vì PoC tối giản thực ra tốt hơn cho disclosure. PoC càng đơn giản, maintainer càng dễ verify ngay mà không cần setup phức tạp. Cả hai CVE đầu được accept trong vòng 3 giờ — mình nghĩ một phần là vì bug rõ ràng đến mức không cần giải thích thêm.

Verify kỹ trước khi submit — false positive làm mất uy tín

Với CVE 3, mình suýt submit nhầm. Ban đầu nhìn vào values.yaml thấy có hooks.nodes.enabled: false và nghĩ node webhook tắt theo mặc định — tức là bug không trigger trong cấu hình thường. Nhưng sau khi đọc kỹ NodeWebhookSupported() trong code thực tế mới hiểu đó là Helm chart toggle riêng, còn logic thật trong code thì chỉ disable trên K8s < 1.21 để tránh CVE-2021-25735. Mọi cluster hiện đại đều bật.

Nếu mình submit mà không verify kỹ, maintainer sẽ close ngay vì “disabled by default” — và lần sau report thật họ cũng sẽ nghi ngờ. Một false positive có thể ảnh hưởng đến credibility lâu dài hơn mình nghĩ.

Responsible disclosure không đáng sợ như nghe có vẻ

Trước khi làm lần đầu, mình cũng không chắc quy trình ra sao, có bị làm khó không, hay maintainer có phản hồi không. Thực tế với Capsule rất thuận: GitHub Security Advisory có UI rõ ràng, report private hoàn toàn, maintainer phản hồi nhanh và professional.

Nếu bạn tìm được bug thật trong một open-source project, cứ report. Worst case là họ close vì “not a bug” và giải thích lý do — đó cũng là thông tin có giá trị. Best case là được credit trên advisory published và CVE ID — cái mà portfolio của bạn sẽ cảm ơn mãi mãi.

Lời cảm ơn

Mình bắt đầu bài này chỉ với mục tiêu “thử xem có tìm được gì không” trong khi nghiên cứu Kubernetes security cho chương trình VDT 2026. Không kỳ vọng gì nhiều, cũng không có kinh nghiệm CVE hunting trước đó.

Thứ giúp mình tìm được không phải là công cụ hay kiến thức đặc biệt — mà là cách tiếp cận: đọc CVE cũ để hiểu pattern, viết script để scale, tìm outlier thay vì đọc tuyến tính, và không dừng lại sau bug đầu tiên.

Ba advisory, hai CVE đã published, một đang triage. Capsule v0.13.7 đã có patch. Mình cảm thấy khá ổn với kết quả này với một đứa mới chập chững vào ngành như mình.

Nếu bạn đang nghiên cứu Kubernetes security hoặc muốn bắt đầu CVE hunting trên open-source, hi vọng writeup này có ích. Không cần phải là expert — cần là người đủ tò mò để hỏi “tại sao chỗ này lại khác?” và đủ kiên nhẫn để đào đến cùng. Và mình cũng muốn cảm ơn chương trình VDT đã tạo điều kiện cho mình có thể có cơ hội tiếp cận được Kubernetes và nhờ đó mình cũng có thể tìm ra được lỗ hổng nho nhỏ này cho cộng đồng.

References

Advisories:

Codebase & Patch:

  • Capsule source
  • v0.13.7 release notes
  • internal/webhook/tenant/validation/hostname_regex.go (CVE 1)
  • internal/webhook/tenant/validation/forbidden_annotations_regex.go (CVE 2)
  • internal/webhook/node/user_metadata.go + pkg/api/forbidden_list.go (CVE 3)

Tài liệu kỹ thuật:

Updated: