Skip to content

You are reading the documentation for Kloak v0.1.0. The latest release is v0.3.0. Read the latest docs

Architecture Overview ​

Kloak is a Kubernetes-native secret protection system that uses eBPF to rewrite secret placeholders with real values at the kernel level, after TLS encryption. Applications never see actual secrets -- they work with harmless kloak: placeholders that are transparently substituted in the encrypted output.

Components ​

Kloak consists of three main components deployed in the kloak-system namespace:

Controller (DaemonSet) ​

The controller runs as a DaemonSet -- one pod per node -- because eBPF programs must be loaded on the same kernel where the target processes run.

It performs four functions:

  1. SecretReconciler -- Watches Kubernetes Secrets labeled getkloak.io/enabled=true. For each enabled secret, creates a shadow secret (<name>-kloak, owned by the original and labeled getkloak.io/managed=true) whose values are kloak: placeholders: kloak: followed by random uppercase letters and digits (from a ULID, random part first), cut or padded to exactly the real value's byte length, with an HPACK Huffman encoding at least as long as the real value's. There is no separate in-memory store: every 5 seconds the controller joins each enabled Secret with its shadow from its informer cache, reads the getkloak.io/hosts / getkloak.io/port labels, and syncs placeholder-prefix -> real-value entries into the secret_map BPF map.

  2. Pod Reconciler -- Watches Pods annotated getkloak.io/enabled=true on the local node. When a matching pod is detected, resolves each container's cgroup ID, then delegates to the TLS Uprobe Manager. Failed attachments are retried every 500ms (handles runtimes like Python where libssl loads lazily).

  3. TLS Uprobe Manager -- Loads eBPF programs into the kernel, attaches uprobes to each container process's TLS write functions, attaches the tc patch program at tc egress on the pod's eth0 and lo interfaces (TCX, inside the pod's network namespace), and syncs the secret map to BPF every 5 seconds. Also tracks process lifecycle (exec/exit) in tracked cgroups to attach uprobes to newly spawned processes.

  4. Trusted DNS Discovery -- Auto-discovers the kube-dns ClusterIP from the kube-system/kube-dns service and populates the trusted_dns_servers BPF map. Only DNS responses from trusted servers are used for host filtering. Additional servers can be configured via controller.dns.trustedServers (the --trusted-dns-servers flag).

Webhook (Deployment) ​

The webhook runs as a standard Deployment (typically 1 replica). It serves a mutating admission webhook for pod CREATE.

One MutatingWebhookConfiguration (kloak-mutating-webhook) contains two webhooks that ensure only kloak-enabled workloads are sent to the webhook (both use failurePolicy: Fail and never match the Kloak release namespace):

  • Namespace-scoped: matches namespaces labeled getkloak.io/enabled=true
  • Pod-scoped: matches pods labeled getkloak.io/enabled=true via objectSelector

Non-kloak workloads are never affected, even if the webhook is down.

When a pod is matched:

  1. Checks if Kloak is enabled for this pod: if the pod has the getkloak.io/enabled label, its value decides ("false" opts out even in an enabled namespace); otherwise the namespace label decides
  2. Scans the pod's volumes[].secret. Environment variable references (secretKeyRef, envFrom) and projected volume sources are not rewritten, so a pod that uses them gets the real value.
  3. For each volume of a kloak-enabled secret, rewrites the name from original to original-kloak
  4. Rejects the pod if any kloak-enabled secret's shadow does not exist yet or is not Kloak-managed (fail-closed)
  5. Adds getkloak.io/enabled: "true" annotation to the pod so the controller knows to attach eBPF uprobes

Secrets are not validated at admission in this release (a validating webhook was added in v0.1.1).

eBPF Programs ​

The eBPF programs run in-kernel and are loaded by the controller. The secret rewriting pipeline has three stages:

Stage 1: Uprobe -- Scan and Compute ​

When SSL_write or crypto/tls.(*Conn).Write is called, the uprobe fires and:

  1. Reads the plaintext write buffer (up to 256 bytes per chunk, scanning the full buffer via bpf_loop)
  2. Resolves the destination hostname via the DNS trust chain (see below)
  3. Pre-scans for the kloak: prefix (or its HPACK Huffman encoding, for HTTP/2), finding up to 4 matches per call
  4. For each match, uses the 8 bytes at the match as the key to look up the real secret value in the secret_map BPF hash map
  5. Checks the resolved hostname and the destination port against the secret's allowed host and port
  6. Computes XOR deltas: xor_delta[i] = shadow_byte[i] ^ real_byte[i] for each matched secret
  7. Stores the pending patches in xor_pending (keyed by process ID / tgid). The plaintext buffer is not modified.

The GHASH key H is obtained per connection. For OpenSSL, the tcp_sendmsg kprobe walks the SSL struct to the GCM context at version-specific offsets (3-hop chain for OpenSSL 3.0/3.1, 4-hop for 3.2+). For Go crypto/tls, the kprobe reads H*2 from the GCM productTable and applies GF(2^128) halving. BoringSSL keeps no H that Kloak can read, so BoringSSL connections are not rewritten.

Stage 2: Kprobe -- Bridge to Network ​

A kprobe on tcp_sendmsg fires when the TLS library sends the encrypted data:

  1. Reads the pending patches from xor_pending
  2. Extracts the source port and destination IP from the socket (IPv4 destinations only)
  3. Obtains H if the uprobe did not (Go, or the OpenSSL fallback walk). If H cannot be obtained, no patch is created and the placeholder is sent.
  4. Builds a tc_pending entry keyed by (destination IP, source port, cgroup ID), recording the TCP write sequence number to identify the right segment
  5. The patches are now ready for the tc program

Stage 3: TC -- Patch Ciphertext ​

A tc (traffic control) program attached at tc egress on the pod's eth0 and lo interfaces, inside the pod's network namespace, as TCX links (Linux 6.6+), intercepts the pod's outbound packets. On older kernels the attach fails and nothing is rewritten. Because the patch happens inside the pod's network namespace, a container in the pod with CAP_NET_RAW could capture the patched ciphertext -- drop NET_RAW for Kloak-enabled workloads. Loopback is patched too, so a TLS server in the same pod can receive the real value of a secret without a host filter.

  1. Looks up tc_pending by matching the packet's destination IP, source port, and cgroup
  2. For each patch, XORs the corresponding ciphertext bytes: CT_real = CT_shadow XOR xor_delta
  3. Recomputes the GHASH authentication tag using precomputed H powers (GF(2^128) multiplication)
  4. Patches the authentication tag in the TLS record
  5. The packet leaves the node with the real secret encrypted -- the pod's memory never held the real value

Why XOR patching works

AES-GCM in counter mode (CTR) encrypts via ciphertext = plaintext XOR keystream. If you know the XOR difference between the shadow and real values, you can patch the ciphertext directly: CT_real = CT_shadow XOR (shadow XOR real). The keystream cancels out. The GHASH tag must be recomputed because the ciphertext changed.

Only AES-128-GCM and AES-256-GCM cipher suites (TLS 1.2 and 1.3) are supported. ChaCha20-Poly1305 connections are not rewritten and send the placeholder.

Go crypto/tls ​

Go crypto/tls uses the same XOR-patch pipeline. There is no plaintext fallback: if H cannot be obtained, the placeholder is sent.

DNS-Verified Host Resolution ​

Additional eBPF programs build a chain of trust from DNS resolution to TLS write:

  • DNS Kprobe (udp_recvmsg) -- Intercepts DNS responses on the node. Validates the source against the trusted_dns_servers whitelist. For hostnames in the watched_hosts set, stores resolved A/AAAA records in dns_ip_map with TTL.
  • Connect Tracepoints (sys_enter/exit_connect) -- Tracks TCP connections (fd to destination IP) in conn_ip_map. When the destination IP exists in dns_ip_map, caches the fd in last_verified_fd for fast lookup.
  • Close Tracepoint (sys_enter_close) -- Cleans up conn_ip_map entries when file descriptors are closed, preventing stale mappings after fd reuse.

At TLS write time, resolve_host() finds the socket fd via ssl_fd_map (cache) -> the fd read from the SSL struct's write BIO -> last_verified_fd, then chains conn_ip_map[{tgid, fd}] -> dns_ip_map[ip] to determine the hostname. If no fd is found, it scans fds 3-30 in conn_ip_map for a DNS-verified destination.

Admission Flow ​

When a secret and pod are created, the controller and webhook set up the shadow secret and mutate the pod:

Secret Rewrite Flow ​

When the application makes a TLS call, the eBPF pipeline rewrites the secret in the encrypted output:

Security Model ​

Real secret values never enter application memory. The application only sees kloak: placeholders. Real values exist in the original Secret (API server/etcd), in the controller's informer cache (which holds all Secrets cluster-wide), and in kernel-space BPF maps -- all inaccessible to application containers. The XOR-patch pipeline injects secrets into the ciphertext after TLS encryption, so they never pass through the application's memory. The patched packets do pass through the pod's network namespace (see above).

For the full threat model, trust chain, fail modes, and known limitations, see the Security Model page.

BPF Map Layout ​

Core Maps ​

MapTypeKeyValuePurpose
secret_mapHashFirst 8 bytes of the placeholder (kloak:xx) or of its HPACK Huffman encodingReal value (128B) + host (64B) + port + protocol + full prefix (42B)Placeholder-to-secret lookup (max 4096 entries; each secret uses up to 2)
tls_conn_stateLRU HashGHASH H (16B) + H powers (16x16B) + cipher typePer-connection TLS state for GHASH recomputation
xor_pendingHashtgidPatches (up to 4) with offset, length, XOR deltaUprobe to kprobe bridge: pending ciphertext patches
tc_pendingLRU HashPatches + TCP write sequenceKprobe to tc bridge: patches for a specific connection
tc_tag_pendingLRU HashGHASH tag deltaDeferred tag fix-up when the tag is in a later TCP segment

DNS and Connection Tracking ​

MapTypeKeyValuePurpose
dns_ip_mapLRU HashIP address (16B)Hostname (64B) + TTL + timestampDNS-verified IP-to-hostname cache
conn_ip_mapLRU HashIP address (16B) + portTCP connection to destination IP
last_verified_fdHashtgidfdLast fd whose IP matched a DNS-verified host
ssl_fd_mapLRU HashfdSSL connection to fd cache
watched_hostsHashHostname (64B)1Set of hostnames to capture DNS for
trusted_dns_serversHashIP address (16B)1Trusted DNS server whitelist

Process and Container Tracking ​

MapTypeKeyValuePurpose
tracked_cgroupsHashcgroup inode ID1Container cgroups whose TLS writes are scanned (registered by the controller, and by the exec tracepoint for container execs)
tracked_tgidsHashtgid1Container processes seen by the exec tracepoint (not used as a filter; DNS and connect tracking are node-wide)

Program Control ​

MapTypeKeyValuePurpose
prog_arrayProgArrayIndex 1-2Program FDsTail calls: 1=XOR patch, 2=H extract
tc_prog_arrayProgArrayIndex 0Program FDTC tail call: GHASH tag recomputation
tls_eventsRingBuf--Event struct (pid, tgid, len, is_rewritten)Reserved for rewrite events to userspace (no program currently emits them)
proc_eventsRingBuf--Exec/exit eventsProcess lifecycle events for uprobe attachment

Released under the GNU AGPL-3.0 License.