Runtime detection and observability

Tetragon: credential file reads, the secure way

Every attacker who lands in a container does the same three things, and "cat the service account token" is one of them. It takes a second, leaves no trace in the app logs, and turns one compromised pod into an API client.

The short answer

Apply a Tetragon TracingPolicy on the security_file_permission hook with Prefix matches for /etc/shadow, SSH keys and the service account token path, limited to container PID namespaces and rate-limited. Hooking the LSM function covers every read system call at once. Alert on reads by unexpected binaries.

Updated Houssam Hammoudi, CTOTested with Tetragon 1.7.1, Kubernetes 1.34 (kind), kernel 6.8

On this page
  1. What goes wrong
  2. What the docs say
  3. The secure configuration
  4. Prove it
  5. Mistakes people make
  6. Checklist

What goes wrong

Credentials sit in files: password hashes in /etc/shadow, SSH keys in ~/.ssh/, and in Kubernetes, a service account token mounted into almost every pod at /var/run/secrets/kubernetes.io/serviceaccount/token.

Reading one of these files is normal for a few programs and suspicious for everything else. A web server has no reason to read its own service account token; a shell that an attacker opened in that web server very much does.

Syscall-based monitoring misses reads that use read, pread, readv, sendfile, mmap or io_uring paths the rules did not list. Monitoring at the security hook in the kernel catches all of them in one place.

What the docs say

Instead of monitoring every system call, we opt to hook into the security_file_permission hook, which is a common execution point for all the above system calls.

Source: Tetragon docs, Filename access

Selectors enable per-hook in-kernel BPF filtering and actions.

Source: Tetragon docs, Selectors

Filtering in the kernel is what makes this affordable: events for files you did not list never leave the kernel. The docs' example watches all of /etc/, which is far too noisy for alerting. Narrow the prefixes to the files that hold secrets.

The secure configuration

Container reads of credential files:

yaml
# Reads of files that hold credentials, from inside containers.
apiVersion: cilium.io/v1alpha1
kind: TracingPolicy
metadata:
  name: credential-file-reads
spec:
  kprobes:
    - call: "security_file_permission"
      syscall: false
      return: true
      args:
        - index: 0
          type: "file"   # the path of the file
        - index: 1
          type: "int"    # 0x04 MAY_READ, 0x02 MAY_WRITE
      returnArg:
        index: 0
        type: "int"
      selectors:
        - matchNamespaces:
            - namespace: Pid
              operator: NotIn
              values: ["host_ns"]
          matchArgs:
            - index: 0
              operator: "Prefix"
              values:
                - "/etc/shadow"
                - "/etc/gshadow"
                - "/etc/sudoers"
                - "/root/.ssh/"
                - "/var/run/secrets/kubernetes.io/serviceaccount/"
                - "/run/secrets/kubernetes.io/serviceaccount/"
          matchActions:
            - action: Post
              rateLimit: "1m"

Node-level secrets, read by anything other than the expected control-plane binaries:

yaml
# Node-level secrets, including host processes. Paths are those of kubeadm-style nodes;
# check where your distribution keeps them, and which binaries read them, with tetra getevents first.
apiVersion: cilium.io/v1alpha1
kind: TracingPolicy
metadata:
  name: host-credential-reads
spec:
  kprobes:
    - call: "security_file_permission"
      syscall: false
      args:
        - index: 0
          type: "file"
        - index: 1
          type: "int"
      selectors:
        - matchArgs:
            - index: 0
              operator: "Prefix"
              values:
                - "/etc/kubernetes/pki/"
                - "/var/lib/kubelet/pki/"
          matchBinaries:
            - operator: "NotIn"
              values:
                - "/usr/bin/kubelet"   # kubeadm DEB/RPM path
                - "/usr/local/bin/kube-apiserver"
                - "/usr/local/bin/etcd"
bash
kubectl apply -f credential-file-reads.yaml -f host-credential-reads.yaml

The kubelet path is the one the kubeadm packages install. The Kubernetes docs list the package contents:

Installs the /usr/bin/kubelet binary.

Source: Kubernetes docs, Configuring each kubelet in your cluster using kubeadm

Other installs put it elsewhere, so read the real path on a node (readlink /proc/$(pgrep -x kubelet)/exe) before you trust the list.

The node-level policy is only useful if its events leave the node. The Tetragon chart's default export deny list drops every event from the host and from kube-system, so host reads of the node PKI never reach the JSON file, even though tetra getevents shows them. Narrow it to {"health_check":true} (see kernel module and BPF loads).

Alert rules in your pipeline, for example: any read of the service account token by a binary that is not the app's main binary; any read of /etc/shadow from a pod; any read under the node PKI paths from a binary outside the allow list.

Prove it

A root alpine:3.22 pod in namespace creds, with the default token mount. The reads:

text
$ kubectl -n creds exec app -- sh -c "cat /var/run/secrets/kubernetes.io/serviceaccount/token | cut -c1-20"
eyJhbGciOiJSUzI1NiIs
$ kubectl -n creds exec app -- sh -c "cat /etc/shadow | head -1"
root:*::0:::::

What Tetragon recorded (tetra getevents -o compact --namespace creds):

text
process creds/app /bin/cat /var/run/secrets/kubernetes.io/serviceaccount/token
read    creds/app /bin/cat /run/secrets/kubernetes.io/serviceaccount/..2026_09_25_00_33_17.379378043/token
exit    creds/app /bin/cat /var/run/secrets/kubernetes.io/serviceaccount/token 0
process creds/app /bin/cat /etc/shadow
read    creds/app /bin/cat /etc/shadow
exit    creds/app /bin/cat /etc/shadow 0

Look at the token line. The command asked for /var/run/..., and the kernel reports /run/secrets/.../..2026_09_25_00_33_17.379378043/token, the real file behind two symlinks. In this image /var/run is a link:

text
$ kubectl -n creds exec app -- ls -la /var/run
lrwxrwxrwx    1 root     root             6 Sep 17 17:34 /var/run -> ../run

Only the /run/secrets/kubernetes.io/serviceaccount/ prefix matched. A policy with the /var/run/... prefix alone would have stayed silent.

The rate limit is per thread. Two head commands in a row are two processes, so both reads were reported.

On the control-plane node, reads of node keys by a shell:

text
read lab-control-plane /usr/bin/cat /etc/kubernetes/pki/ca.key
read lab-control-plane /usr/bin/head /var/lib/kubelet/pki/kubelet.key

A limit of this lab: kind runs every node as a container on one kernel. There, Tetragon reported the key reads of the running kube-apiserver (host PID 11781, whose /proc/11781/exe is /usr/local/bin/kube-apiserver) with the binary /usr/lib/systemd/systemd. The process started before Tetragon did. So kind cannot prove the matchBinaries allow list. On real nodes, check what tetra getevents shows for your control-plane binaries before you rely on the list.

Mistakes people make

Watching read() instead of the security hook

Attackers have many ways to read a file. The security hook sees them all; a list of syscalls always misses one.

Prefixes that are too wide

/etc/ or / produces thousands of events per second on a busy node. List the files that hold secrets, and add paths as you learn.

The service account token is a symlink, through ..data, into a timestamped directory, and /var/run is often a link to /run. Match on directory prefixes for both spellings, not on the exact file name.

An allow list copied from another install

matchBinaries compares full paths. /usr/local/bin/kubelet does not match a kubelet installed at /usr/bin/kubelet, and every kubelet read of its own keys becomes an alert. Noise like that gets the policy turned off. Take the paths from your own nodes.

Detection without prevention

The best result is a pod that has no token to read. Set automountServiceAccountToken: false where the app does not call the API, and keep this policy for the pods that must have one.

Checklist

  • A policy on security_file_permission covers /etc/shadow, SSH keys and the service account token path.
  • The policy is limited to container PID namespaces and rate-limited.
  • Node PKI and kubelet credential paths are watched with an allow list of binaries taken from your own nodes.
  • Alerts fire on reads by binaries that are not the workload's own.
  • Pods that do not need the API have automountServiceAccountToken: false.
  • The policy is reviewed when new secret paths appear (new operators, new mounts).

A credential read is the quietest step of an attack and often the most valuable. Make the kernel tell you when it happens.

H2-CTDE

Learn it on a live range

Kernel-level detection with eBPF, in Runtime Detection and Response: a real host in your browser, and every objective checked on the machine.

Start free

The Secure Way

More on runtime detection and observability

Tetragon, alerting as code, multi-tenant logs and knowing when a sensor goes quiet.

All runtime detection and observability guides