<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Kazuru</title>
    <description>The latest articles on DEV Community by Kazuru (@kazuru_73322ef9a7d6ed2b18).</description>
    <link>https://dev.to/kazuru_73322ef9a7d6ed2b18</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4014111%2F923ced4c-8fe9-450f-8224-d2ddb3524792.png</url>
      <title>DEV Community: Kazuru</title>
      <link>https://dev.to/kazuru_73322ef9a7d6ed2b18</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/kazuru_73322ef9a7d6ed2b18"/>
    <language>en</language>
    <item>
      <title>Path Consistency in Kubernetes DNS Stacks: Do Resolvers Agree Under Adversarial Conditions?</title>
      <dc:creator>Kazuru</dc:creator>
      <pubDate>Sat, 18 Jul 2026 16:03:04 +0000</pubDate>
      <link>https://dev.to/kazuru_73322ef9a7d6ed2b18/path-consistency-in-kubernetes-dns-stacks-do-resolvers-agree-under-adversarial-conditions-4b6g</link>
      <guid>https://dev.to/kazuru_73322ef9a7d6ed2b18/path-consistency-in-kubernetes-dns-stacks-do-resolvers-agree-under-adversarial-conditions-4b6g</guid>
      <description>&lt;p&gt;Most Kubernetes nodes do not have one DNS. They have a path: pod stub / libc, often systemd-resolved on the host, sometimes NodeLocal DNSCache, then CoreDNS, then upstream. The security question that matters to operators is not which recursive binary won a paper bake-off. It is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Does hop &lt;em&gt;N&lt;/em&gt; on my path accept something hop &lt;em&gt;N+1&lt;/em&gt; would reject?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is path-consistency. Existing differential fuzzers mostly answer a different question (peer-consistency). This post introduces StackDiff: a lab harness for the path question, with an honest scope about what plain containers can and cannot measure.&lt;/p&gt;

&lt;p&gt;This is a methodology post. No findings tables, no CVE counts, no Klein/SAD reproduction, and not a claim that a full Kubernetes path is already instrumented.&lt;/p&gt;

&lt;h2&gt;
  
  
  Path vs peer
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2Fkazuru-chidumbwe%2Fdns-stack-diff-harness%2Fblog-dns01-2026-07%2Fdocs%2Fdiagram-path-vs-peer.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2Fkazuru-chidumbwe%2Fdns-stack-diff-harness%2Fblog-dns01-2026-07%2Fdocs%2Fdiagram-path-vs-peer.png" alt="Peer-consistency (ResolFuzz / ResolverFuzz) vs path-consistency (StackDiff)" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Effective cluster posture is the weakest agreeing hop, not the strongest row in a comparison table.&lt;/p&gt;

&lt;h2&gt;
  
  
  Threat model
&lt;/h2&gt;

&lt;p&gt;Every profile declares three fields before it counts:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Adversary position&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;on-path&lt;/code&gt; / &lt;code&gt;off-path&lt;/code&gt; / &lt;code&gt;malicious-but-trusted-upstream&lt;/code&gt; / &lt;code&gt;none&lt;/code&gt; (smoke)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Adversary capability&lt;/td&gt;
&lt;td&gt;What they can manipulate: response content, timing, races, ICMP, and so on&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Win condition&lt;/td&gt;
&lt;td&gt;What success means here, for example one hop caches what another rejects&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every profile also pins DNSSEC posture (&lt;code&gt;matched&lt;/code&gt; vs &lt;code&gt;deliberately_mismatched&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"dnssec_posture"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"matched"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"unbound"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"off-lab-zone"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"dnsmasq"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"off"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"notes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Matched non-validating so Unbound defaults cannot manufacture a divergence"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Unbound validates by default; dnsmasq does not unless configured. An unpinned mismatch manufactures fake Class A “findings” that are really config accidents.&lt;/p&gt;

&lt;p&gt;Path-consistency win: disagreement along the path so the cluster’s effective posture becomes the weakest agreeing hop. Smoke profile &lt;code&gt;P-SMOKE-AGREE&lt;/code&gt; uses position &lt;code&gt;none&lt;/code&gt; and matched non-validating DNSSEC. Divergence on the smoke axes is a harness failure, not a win.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scope / isolation
&lt;/h2&gt;

&lt;p&gt;Containers share a kernel. That matters for some DNS attacks and not others.&lt;/p&gt;

&lt;p&gt;Klein targets the Linux kernel’s shared &lt;code&gt;prandom&lt;/code&gt; generator (UDP source ports, IPv6 flow labels, IPv4 ID). The paper (&lt;a href="https://arxiv.org/abs/2012.07432" rel="noopener noreferrer"&gt;arXiv:2012.07432&lt;/a&gt;) shows the attack can run locally across users and across containers on the same host. Shared-kernel Docker does not isolate it. Klein-class profiles are deferred to Firecracker / Kata / full VMs.&lt;/p&gt;

&lt;p&gt;SAD DNS (CVE-2020-25705) uses ICMP rate limiting as a side channel. Two kernel changes matter, and they are not the same thing:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Primary mitigation: Linux randomized the ICMP global rate limiter (&lt;a href="https://github.com/torvalds/linux/commit/b38e7819cae946e2edf869e604af1e65a5d241c5" rel="noopener noreferrer"&gt;&lt;code&gt;b38e7819cae946e2edf869e604af1e65a5d241c5&lt;/code&gt;&lt;/a&gt;), mainline 5.10+ with wide LTS backports.&lt;/li&gt;
&lt;li&gt;Isolation improvement: msgs-per-sec token state moved from host-wide to per-netns between v6.11 and v6.12 (&lt;a href="https://github.com/torvalds/linux/commit/b056b4cd9178f7a1d5d57f7b48b073c29729ddaa" rel="noopener noreferrer"&gt;&lt;code&gt;b056b4cd9178f7a1d5d57f7b48b073c29729ddaa&lt;/code&gt;&lt;/a&gt;). That changes whether two containers share the bucket, not whether the side channel was already noised.&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Plain Docker?&lt;/th&gt;
&lt;th&gt;This release&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Application-layer outputs (RCODE, RRset, flags, hang/crash)&lt;/td&gt;
&lt;td&gt;Valid&lt;/td&gt;
&lt;td&gt;In scope&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Klein-class shared &lt;code&gt;prandom&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Not isolated&lt;/td&gt;
&lt;td&gt;Deferred&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SAD DNS-class ICMP side channel&lt;/td&gt;
&lt;td&gt;Randomization ≠ netns isolation&lt;/td&gt;
&lt;td&gt;Deferred until kernel pin&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Ubuntu 24.04 and many enterprise kernels are often still v6.11-shaped for the msgs-per-sec bucket. Always record &lt;code&gt;uname -r&lt;/code&gt; and the distro package. Prefer full commit URLs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related work
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Venue&lt;/th&gt;
&lt;th&gt;Question&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;ResolFuzz (Bushart &amp;amp; Rossow)&lt;/td&gt;
&lt;td&gt;ESORICS 2023&lt;/td&gt;
&lt;td&gt;Do recursive resolvers diverge on mutated inputs?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ResolverFuzz (Zhang et al.)&lt;/td&gt;
&lt;td&gt;USENIX Security 2024&lt;/td&gt;
&lt;td&gt;Query–response fuzzing and differential clustering; real cache-poisoning-class bugs with CVE follow-through&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;StackDiff does not replace either tool. It asks a path-consistency question under pinned application-layer profiles.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture (pair mode)
&lt;/h2&gt;

&lt;p&gt;The public pin tests resolver pairs under identical conditions so the oracle can be trusted before anyone treats divergences as security-relevant. A full Kubernetes path (stub → node-local → CoreDNS → upstream) is a different measurement unit. Pair mode asks whether two path-role stand-ins agree; it does not claim the whole chain is instrumented.&lt;/p&gt;

&lt;p&gt;Unbound and dnsmasq are those stand-ins: dnsmasq approximates local forwarder/cache behavior (NodeLocal DNSCache / systemd-resolved); Unbound approximates a recursive hop. Smoke keeps Unbound forward-only against the same lab authority as dnsmasq, with matched non-validating DNSSEC.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Class&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;th&gt;Rule&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A&lt;/td&gt;
&lt;td&gt;Documented / RFC-allowed disagreement&lt;/td&gt;
&lt;td&gt;Report; do not hype&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;B&lt;/td&gt;
&lt;td&gt;Emergent, security-relevant&lt;/td&gt;
&lt;td&gt;Root-cause and disclosure first&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;C&lt;/td&gt;
&lt;td&gt;Timing, nondeterminism, harness bug&lt;/td&gt;
&lt;td&gt;Fix harness; never publish as finding&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Smoke gate
&lt;/h2&gt;

&lt;p&gt;Both resolvers must return &lt;code&gt;RCODE=NOERROR&lt;/code&gt;, the same answer RRset containing &lt;code&gt;203.0.113.10&lt;/code&gt; for &lt;code&gt;agree.lab.stackdiff. A&lt;/code&gt;, and neither side may hang or hard-error. Any mismatch on those axes is Class C / stop. AA/RA are informational only.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Profile&lt;/th&gt;
&lt;th&gt;Resolvers&lt;/th&gt;
&lt;th&gt;RCODE&lt;/th&gt;
&lt;th&gt;RRset&lt;/th&gt;
&lt;th&gt;Hang/crash&lt;/th&gt;
&lt;th&gt;Class&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;P-SMOKE-AGREE&lt;/td&gt;
&lt;td&gt;Unbound ↔ dnsmasq&lt;/td&gt;
&lt;td&gt;agree&lt;/td&gt;
&lt;td&gt;agree&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;pass&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;On this forward-only path, dnsmasq often sets &lt;code&gt;aa=true&lt;/code&gt; while Unbound sets &lt;code&gt;aa=false&lt;/code&gt; for the same answers. That is expected noise, not a smoke failure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lab pin
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Repo&lt;/td&gt;
&lt;td&gt;&lt;a href="https://github.com/kazuru-chidumbwe/dns-stack-diff-harness" rel="noopener noreferrer"&gt;dns-stack-diff-harness&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tag&lt;/td&gt;
&lt;td&gt;&lt;code&gt;blog-dns01-2026-07&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Host&lt;/td&gt;
&lt;td&gt;Ubuntu 24.04.4 LTS (&lt;code&gt;stackdiff-lab&lt;/code&gt;), QEMU/KVM&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;uname -r&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;6.8.0-134-generic&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kernel package&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;linux-image-6.8.0-134-generic&lt;/code&gt; &lt;code&gt;6.8.0-134.134&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Docker / Compose&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;29.1.3&lt;/code&gt; / &lt;code&gt;2.40.3&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Run&lt;/td&gt;
&lt;td&gt;&lt;code&gt;smoke-20260718T125325Z&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;pass&lt;/code&gt; / divergences&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;true&lt;/code&gt; / &lt;code&gt;0&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Manifest&lt;/td&gt;
&lt;td&gt;&lt;a href="https://github.com/kazuru-chidumbwe/dns-stack-diff-harness/blob/blog-dns01-2026-07/artifacts/smoke-20260718T125325Z/manifest.json" rel="noopener noreferrer"&gt;&lt;code&gt;artifacts/smoke-20260718T125325Z/manifest.json&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Manifest SHA-256&lt;/td&gt;
&lt;td&gt;&lt;code&gt;6804627105cd22b51b35e9df1c713f2fe26c5c4d67abb81bfdd2064be99e0560&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This host is v6.11-shaped for the ICMP msgs-per-sec bucket. Do not claim per-netns isolation here. Cite the pin (&lt;code&gt;uname -r&lt;/code&gt; and the committed manifest SHA-256), not &lt;code&gt;main&lt;/code&gt; plus whatever Docker Desktop shipped. The published &lt;code&gt;uname_a&lt;/code&gt; uses the public alias &lt;code&gt;stackdiff-lab&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reproduce
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/kazuru-chidumbwe/dns-stack-diff-harness
&lt;span class="nb"&gt;cd &lt;/span&gt;dns-stack-diff-harness
git checkout blog-dns01-2026-07
&lt;span class="c"&gt;# Verify the frozen pin (exact hash of the committed file):&lt;/span&gt;
&lt;span class="nb"&gt;sha256sum &lt;/span&gt;artifacts/smoke-20260718T125325Z/manifest.json
&lt;span class="c"&gt;# expect: 6804627105cd22b51b35e9df1c713f2fe26c5c4d67abb81bfdd2064be99e0560&lt;/span&gt;

docker compose &lt;span class="nt"&gt;-f&lt;/span&gt; deploy/compose.yaml up &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--build&lt;/span&gt;
make smoke
&lt;span class="c"&gt;# Your new artifacts/smoke-* run will have a different SHA (timestamps differ).&lt;/span&gt;
&lt;span class="c"&gt;# What should match the pin: pass=true and oracle.divergence_count=0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Needs Docker Compose and Python 3.12+. Lab DNS only.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this post is not claiming
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;New CVEs or “N exploitable divergences”&lt;/li&gt;
&lt;li&gt;Klein under Docker, or SAD DNS without a pinned kernel story&lt;/li&gt;
&lt;li&gt;A replacement for ResolFuzz or ResolverFuzz&lt;/li&gt;
&lt;li&gt;That pair mode already is a full stub → node-local → CoreDNS path&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It claims a path-consistency instrument (pair-validated first), an explicit isolation boundary, and a smoke gate that is green on the pin above. The harness is public on that pin; this post is methodology only. If you want harness updates, watch &lt;a href="https://github.com/kazuru-chidumbwe/dns-stack-diff-harness" rel="noopener noreferrer"&gt;dns-stack-diff-harness&lt;/a&gt; on GitHub.&lt;/p&gt;

</description>
      <category>dns</category>
      <category>kubernetes</category>
      <category>security</category>
      <category>testing</category>
    </item>
    <item>
      <title>A Differential Test Harness for Native vs. Generic XDP: Methodology and Baseline</title>
      <dc:creator>Kazuru</dc:creator>
      <pubDate>Tue, 14 Jul 2026 03:34:29 +0000</pubDate>
      <link>https://dev.to/kazuru_73322ef9a7d6ed2b18/a-differential-test-harness-for-native-vs-generic-xdp-methodology-and-baseline-4h1a</link>
      <guid>https://dev.to/kazuru_73322ef9a7d6ed2b18/a-differential-test-harness-for-native-vs-generic-xdp-methodology-and-baseline-4h1a</guid>
      <description>&lt;p&gt;Native XDP and generic SKB-mode XDP are not the same thing in practice. The same BPF program can pass the verifier and still behave differently depending on which mode the kernel uses, this could be a different verdict, different frame bytes, or different metadata. This post ships three things: an open differential test harness, a fixed eleven-packet corpus, and a simple way to classify the differences it finds. A tagged release lets anyone reproduce the virtio/veth baseline on Linux 6.8.&lt;/p&gt;

&lt;p&gt;The operational risk is straightforward. A firewall or rate-limiter validated only under native XDP can fall back to generic mode on an unsupported driver, a veth port, or after a reload. You keep the same bytecode, but behaviour can change, often without a clear error line.&lt;/p&gt;

&lt;p&gt;What this release includes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A harness loop: corpus → inject on the RX path → native vs generic sweep → &lt;code&gt;xdpdump&lt;/code&gt; capture → &lt;code&gt;compare.py&lt;/code&gt; manifest, comparing both the captured frame bytes and the XDP verdict (&lt;code&gt;PASS&lt;/code&gt;/&lt;code&gt;DROP&lt;/code&gt;/&lt;code&gt;TX&lt;/code&gt;/&lt;code&gt;REDIRECT&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;A deterministic corpus with eleven embedded test IDs (&lt;code&gt;0xA001&lt;/code&gt;–&lt;code&gt;0xA005&lt;/code&gt;, &lt;code&gt;0xA007&lt;/code&gt;–&lt;code&gt;0xA00C&lt;/code&gt;; &lt;code&gt;0xA006&lt;/code&gt; is intentionally omitted as a reserved gap in the generator).&lt;/li&gt;
&lt;li&gt;An operational divergence taxonomy (Class A / B / C).&lt;/li&gt;
&lt;li&gt;A virtio/veth smoke gate on Linux 6.8; now gating on frame bytes and verdict agreement that shows the full path is reproducible end to end.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Scope for this post: native vs generic XDP on the &lt;code&gt;virtio_vm&lt;/code&gt; profile only (five BPF programs, pinned manifests). This is part 1 of 2; it establishes the harness and an instrument-validity baseline; a follow-up post covers bare-metal divergence results. Physical NIC results are not part of this baseline.&lt;/p&gt;

&lt;p&gt;Ordinary conformance checks stop at “did the program load?” Differential testing asks a sharper question: given identical input packets, do the backends produce the same observable outcome at the hook?&lt;/p&gt;

&lt;h2&gt;
  
  
  Background: native vs generic XDP
&lt;/h2&gt;

&lt;p&gt;Both modes load the same BPF object. They diverge at the hook point and in how the packet is represented.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;Native XDP&lt;/th&gt;
&lt;th&gt;Generic XDP (SKB)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Hook point&lt;/td&gt;
&lt;td&gt;Driver ingress (&lt;code&gt;ndo_xdp&lt;/code&gt;, before &lt;code&gt;sk_buff&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;netif_receive_skb()&lt;/code&gt; path (&lt;code&gt;xdpgeneric&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sk_buff&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;No — works on the driver frame / &lt;code&gt;xdp_buff&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Yes — packet is SKB-backed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;data_meta&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Depends on driver and headroom&lt;/td&gt;
&lt;td&gt;Often NULL or reduced headroom&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;VLAN tags&lt;/td&gt;
&lt;td&gt;As presented at driver ingress (unless RX VLAN offload strips early)&lt;/td&gt;
&lt;td&gt;Same offload confounds; path still differs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fragments&lt;/td&gt;
&lt;td&gt;Driver-dependent layout&lt;/td&gt;
&lt;td&gt;May be linearised or presented differently&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Checksum&lt;/td&gt;
&lt;td&gt;Hardware offload may apply before the hook&lt;/td&gt;
&lt;td&gt;Software path is more common&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Typical throughput&lt;/td&gt;
&lt;td&gt;NIC-limited (tens of Mpps class)&lt;/td&gt;
&lt;td&gt;Lower (single-digit Mpps class on the same hardware)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Generic XDP exists so programs can run where drivers lack native support. That convenience costs the early-hook semantics people often assume when they test on one NIC and deploy on another.&lt;/p&gt;

&lt;p&gt;Hardware SmartNIC signed offload is out of scope for this harness.&lt;/p&gt;

&lt;h2&gt;
  
  
  Corpus (eleven packets)
&lt;/h2&gt;

&lt;p&gt;Each frame carries a test ID in the TCP/UDP source port, ICMP id, or payload tail so &lt;code&gt;compare.py&lt;/code&gt; can pair captures without relying on timing.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Test ID&lt;/th&gt;
&lt;th&gt;Packet&lt;/th&gt;
&lt;th&gt;Exercises&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;prog_pass_drop&lt;/code&gt; expected&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0xA001&lt;/td&gt;
&lt;td&gt;IPv4 TCP SYN&lt;/td&gt;
&lt;td&gt;Basic L3/L4 parse&lt;/td&gt;
&lt;td&gt;PASS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0xA002&lt;/td&gt;
&lt;td&gt;IPv6 TCP SYN&lt;/td&gt;
&lt;td&gt;Non-IPv4 ethertype (program passes non-IPv4)&lt;/td&gt;
&lt;td&gt;PASS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0xA003&lt;/td&gt;
&lt;td&gt;802.1Q VLAN&lt;/td&gt;
&lt;td&gt;Single tag&lt;/td&gt;
&lt;td&gt;PASS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0xA004&lt;/td&gt;
&lt;td&gt;QinQ&lt;/td&gt;
&lt;td&gt;Double VLAN&lt;/td&gt;
&lt;td&gt;PASS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0xA005&lt;/td&gt;
&lt;td&gt;IPv4 fragment&lt;/td&gt;
&lt;td&gt;Fragment / incomplete L4&lt;/td&gt;
&lt;td&gt;PASS (a non-first fragment carries no TCP header, so the &lt;code&gt;sport == 0xA005&lt;/code&gt; drop branch is never reached — the drop path is effectively dead against this corpus)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0xA009&lt;/td&gt;
&lt;td&gt;ICMP echo&lt;/td&gt;
&lt;td&gt;Non-TCP/UDP&lt;/td&gt;
&lt;td&gt;PASS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0xA008&lt;/td&gt;
&lt;td&gt;UDP zero checksum&lt;/td&gt;
&lt;td&gt;UDP / checksum edge&lt;/td&gt;
&lt;td&gt;PASS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0xA007&lt;/td&gt;
&lt;td&gt;TCP FIN/ACK&lt;/td&gt;
&lt;td&gt;TCP flags&lt;/td&gt;
&lt;td&gt;PASS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0xA00A&lt;/td&gt;
&lt;td&gt;TCP zero payload&lt;/td&gt;
&lt;td&gt;Empty payload&lt;/td&gt;
&lt;td&gt;PASS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0xA00B&lt;/td&gt;
&lt;td&gt;MTU-sized fill&lt;/td&gt;
&lt;td&gt;Large frame&lt;/td&gt;
&lt;td&gt;PASS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0xA00C&lt;/td&gt;
&lt;td&gt;DSCP/ECN&lt;/td&gt;
&lt;td&gt;IP TOS field&lt;/td&gt;
&lt;td&gt;PASS&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Coverage spans L2 (VLAN/QinQ), L3 (IPv4/IPv6/frag/TOS), L4 (TCP/UDP/ICMP), a checksum edge, and an MTU fill. Companion programs isolate other behaviours: &lt;code&gt;prog_metadata_test&lt;/code&gt; (&lt;code&gt;data_meta&lt;/code&gt; headroom), &lt;code&gt;prog_vlan&lt;/code&gt; (802.1Q rewrite), &lt;code&gt;prog_l3_modify&lt;/code&gt; (TTL decrement), and &lt;code&gt;prog_redirect&lt;/code&gt; (same-port &lt;code&gt;XDP_REDIRECT&lt;/code&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  What the comparator measures
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;compare.py&lt;/code&gt; compares two things per test ID: the frame bytes, and the XDP verdict.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Pairing — pull the test ID out of each captured frame (payload tail, TCP/UDP source port, or ICMP id, in &lt;code&gt;0xA000&lt;/code&gt;–&lt;code&gt;0xA0FF&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Capture point — sweeps use &lt;code&gt;xdpdump --rx-capture=exit&lt;/code&gt;, so we see the frame after the program runs. (Default &lt;code&gt;xdpdump&lt;/code&gt; captures at entry only.)&lt;/li&gt;
&lt;li&gt;Frame fingerprint — SHA-256 of the captured bytes. A test ID is &lt;code&gt;equivalent: true&lt;/code&gt; only when both backends captured it and the fingerprints match. If an ID shows up on one backend but not the other, that is a divergence.&lt;/li&gt;
&lt;li&gt;Verdict — the XDP action: &lt;code&gt;PASS&lt;/code&gt;, &lt;code&gt;DROP&lt;/code&gt;, &lt;code&gt;TX&lt;/code&gt;, or &lt;code&gt;REDIRECT&lt;/code&gt;. &lt;code&gt;xdpdump&lt;/code&gt; prints this in its text output (&lt;code&gt;xdp_prog()@exit[DROP]&lt;/code&gt;), not in the &lt;code&gt;-w&lt;/code&gt; pcapng frame. So the sweep runs a second &lt;code&gt;xdpdump -x&lt;/code&gt; pass, and &lt;code&gt;compare.py&lt;/code&gt; records a &lt;code&gt;verdict&lt;/code&gt; and &lt;code&gt;verdict_match&lt;/code&gt; per test ID plus a top-level &lt;code&gt;verdict_divergence_count&lt;/code&gt;. This is independent of the bytes: if one mode drops a packet the other passes without changing the frame, the fingerprint misses it — the verdict catches it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;One axis is still not measured: context metadata. &lt;code&gt;data_meta&lt;/code&gt;, &lt;code&gt;ingress_ifindex&lt;/code&gt;, and &lt;code&gt;rx_queue_index&lt;/code&gt; live in &lt;code&gt;xdp_md&lt;/code&gt;, and never show up in the frame or in xdpdump's text. Two backends could disagree there while the bytes and the verdict both match. Reading it needs a small metadata-echo BPF program, which is planned for the bare-metal follow-up. (Class C — an observation limit, not a bug.)&lt;/p&gt;

&lt;p&gt;A note on determinism: these programs are safe to compare byte-for-byte. &lt;code&gt;prog_pass_drop&lt;/code&gt; does not touch headers, packets only traverse ingress RX (no routing), and veth injection does not rewrite TTL or IP ID. Header-mutating programs (&lt;code&gt;prog_l3_modify&lt;/code&gt;, &lt;code&gt;prog_vlan&lt;/code&gt;) do change bytes, and the exit capture reflects those changes.&lt;/p&gt;

&lt;p&gt;Three pitfalls, named once: inject on a peer interface (the RX path), not with &lt;code&gt;tcpreplay&lt;/code&gt; onto the attach NIC; detach XDP between backend runs; label every manifest by profile.&lt;/p&gt;

&lt;h2&gt;
  
  
  Divergence taxonomy
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Class&lt;/th&gt;
&lt;th&gt;Definition&lt;/th&gt;
&lt;th&gt;Action&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A&lt;/td&gt;
&lt;td&gt;Difference described in kernel docs or known backend semantics (for example documented &lt;code&gt;data_meta&lt;/code&gt; absence in generic mode).&lt;/td&gt;
&lt;td&gt;Record it; do not treat it as a harness bug.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;B&lt;/td&gt;
&lt;td&gt;Same corpus and controlled NIC config; difference is not documented but reproducible on standard hardware.&lt;/td&gt;
&lt;td&gt;Investigate; consider a kernel/driver report.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;C&lt;/td&gt;
&lt;td&gt;Caused by uncontrolled setup (offload left on, wrong inject path, capture race, mixed profiles) — or by a measurement limit of the harness.&lt;/td&gt;
&lt;td&gt;Fix the environment or document the limit; exclude from “surprise” analysis.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Harness architecture
&lt;/h2&gt;

&lt;p&gt;On the &lt;code&gt;virtio_vm&lt;/code&gt; profile, &lt;code&gt;topology-veth.sh&lt;/code&gt; creates &lt;code&gt;veth-a&lt;/code&gt; / &lt;code&gt;veth-b&lt;/code&gt;, moves &lt;code&gt;veth-a&lt;/code&gt; into netns &lt;code&gt;xdpequiv-inj&lt;/code&gt;, and attaches XDP on &lt;code&gt;veth-b&lt;/code&gt; in the host namespace. Injection on &lt;code&gt;veth-a&lt;/code&gt; puts packets onto &lt;code&gt;veth-b&lt;/code&gt; on the RX path — where native vs generic semantics actually differ.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;generate_corpus.py → corpus.pcap
        │
        ▼
inject.py (netns xdpequiv-inj, iface veth-a)
        │
        ▼
veth peer → veth-b [XDP: native | generic]
        │
        ▼
xdpdump → captures/output_&amp;lt;backend&amp;gt;_pass_drop.pcap
        │
        ▼
compare.py → manifests/run_manifest_virtio_vm_pass_drop.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Lab profile for this post
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Profile&lt;/th&gt;
&lt;th&gt;Topology&lt;/th&gt;
&lt;th&gt;Hardware&lt;/th&gt;
&lt;th&gt;Entry point&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;virtio_vm&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;veth + netns inject&lt;/td&gt;
&lt;td&gt;Any Linux VM / host&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;sudo make topology &amp;amp;&amp;amp; sudo make sweep-virtio&lt;/code&gt; or &lt;code&gt;bash scripts/smoke.sh&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;scripts/smoke.sh&lt;/code&gt; wraps corpus + build + topology + sweep and checks a minimum case count — treat it as the pass/fail gate. &lt;code&gt;make sweep-virtio&lt;/code&gt; runs one program; &lt;code&gt;bash scripts/sweep-all-virtio.sh&lt;/code&gt; sweeps all five blog programs on veth.&lt;/p&gt;

&lt;h2&gt;
  
  
  Results
&lt;/h2&gt;

&lt;p&gt;We ran the harness on the &lt;code&gt;virtio_vm&lt;/code&gt; profile (Linux 6.8.0-134-generic, veth + netns inject) across five programs:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Program&lt;/th&gt;
&lt;th&gt;Cases paired&lt;/th&gt;
&lt;th&gt;Byte divergences&lt;/th&gt;
&lt;th&gt;Verdict (both backends)&lt;/th&gt;
&lt;th&gt;Verdict divergences&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;prog_pass_drop&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;11 / 11&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;PASS&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;prog_metadata_test&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;11 / 11&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;DROP&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;prog_vlan&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;11 / 11&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;PASS&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;prog_l3_modify&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;11 / 11&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;PASS&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;prog_redirect&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;11 / 11&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;REDIRECT&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Pinned manifests live in the repository as &lt;code&gt;manifests/run_manifest_virtio_vm_&amp;lt;program&amp;gt;.json&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Zero byte divergences means native and generic produced identical exit-capture bytes for these programs on this topology. Zero verdict divergences means they also returned the same XDP action for every packet. So both modes agree on both axes — but only here. This says nothing about other NICs, and the context axis is still unmeasured.&lt;/p&gt;

&lt;p&gt;Two program-specific notes:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;prog_pass_drop&lt;/code&gt; comes back &lt;code&gt;PASS&lt;/code&gt; on every test ID. That is because the corpus never triggers the program's &lt;code&gt;DROP&lt;/code&gt; branch. The branch fires on &lt;code&gt;TCP sport == 0xA005&lt;/code&gt;, but case 05 is a non-first IP fragment with no TCP header — the bytes read at the L4 offset are never &lt;code&gt;0xA005&lt;/code&gt;. So the drop path is dead against this corpus. A packet that actually triggers it is left to the bare-metal follow-up, which rebuilds and re-verifies the corpus anyway. This is worth stating plainly: verdict capture works, and on this corpus there is simply no drop to catch yet.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;prog_metadata_test&lt;/code&gt; comes back &lt;code&gt;DROP&lt;/code&gt; on every test ID, on both backends. The frame fingerprint alone could never have shown that — the program does not touch the payload, so the bytes match either way. The verdict axis is what surfaces it. The two modes agree on the action; they may still differ on &lt;code&gt;data_meta&lt;/code&gt; headroom itself, which we do not capture, so treat that as unmeasured.&lt;/p&gt;

&lt;p&gt;Two observation limits remain for this baseline:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;prog_redirect&lt;/code&gt;: both backends return &lt;code&gt;REDIRECT&lt;/code&gt; and the exit-capture bytes match, but the harness captures on the attach interface only. Full equivalence would also require confirming the packet reached the redirect destination with matching bytes — a Class C limit for this program.&lt;/li&gt;
&lt;li&gt;VLAN offload: the corpus includes 802.1Q and QinQ, but the harness does not yet verify whether RX VLAN offload strips tags before XDP sees them (more likely under generic mode). &lt;code&gt;prog_vlan_probe&lt;/code&gt; ships for this check, but its output is not part of this baseline.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Validation
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Smoke gate (&lt;code&gt;prog_pass_drop&lt;/code&gt;)
&lt;/h3&gt;

&lt;p&gt;On Linux 6.8.0-134-generic (lab VM, virtio/veth profile):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Corpus cases paired&lt;/td&gt;
&lt;td&gt;11 / 11&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Capture fingerprint mismatches (native vs generic)&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Verdict mismatches (native vs generic)&lt;/td&gt;
&lt;td&gt;0 (all PASS)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;scripts/smoke.sh&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;PASS&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The gate now checks both axes: the fingerprints must match, and &lt;code&gt;verdict_divergence_count&lt;/code&gt; must be 0. That closes an earlier hole. For a non-mutating program like &lt;code&gt;prog_pass_drop&lt;/code&gt;, the frame bytes are identical whether the packet is passed or dropped — so a byte-only gate would stay green even if the two modes disagreed on the action, which is the difference that matters most. It is still a backstop on a trivial topology, not a claim of universal equivalence.&lt;/p&gt;

&lt;h3&gt;
  
  
  Comparator sensitivity (synthetic)
&lt;/h3&gt;

&lt;p&gt;Four synthetic self-tests exercise &lt;code&gt;compare.py&lt;/code&gt; with no kernel required:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;comparator-selftest.py&lt;/code&gt; — same test ID, different post-hook bytes (a TTL change). Expect &lt;code&gt;divergence_count: 1&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;comparator-missing-tid-test.py&lt;/code&gt; — a test ID present on only one backend. Expect a divergence.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;comparator-blindspot-demo.py&lt;/code&gt; — identical bytes on both backends. Expect &lt;code&gt;divergence_count: 0&lt;/code&gt; (byte agreement alone does not prove context agreement).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;comparator-verdict-selftest.py&lt;/code&gt; — same test ID and identical bytes, but opposite verdicts (&lt;code&gt;PASS&lt;/code&gt; vs &lt;code&gt;DROP&lt;/code&gt;). Expect &lt;code&gt;divergence_count: 0&lt;/code&gt; and &lt;code&gt;verdict_divergence_count: 1&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The last test is the point: it proves the verdict axis catches a disposition mismatch that the byte fingerprint cannot. Together the four cover both axes the comparator measures, and document the one it does not (context).&lt;/p&gt;

&lt;p&gt;Run the full validation bundle:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bash scripts/comparator-sensitivity.sh   &lt;span class="c"&gt;# self-test + optional live probes&lt;/span&gt;
&lt;span class="c"&gt;# or: make validate-comparator&lt;/span&gt;
bash scripts/sweep-all-virtio.sh         &lt;span class="c"&gt;# all five programs on veth&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Live backend probes (informational)
&lt;/h3&gt;

&lt;p&gt;The repository also ships &lt;code&gt;prog_metadata_test&lt;/code&gt;, &lt;code&gt;prog_vlan&lt;/code&gt;, &lt;code&gt;prog_l3_modify&lt;/code&gt;, &lt;code&gt;prog_redirect&lt;/code&gt;, and &lt;code&gt;prog_vlan_probe&lt;/code&gt; (an 802.1Q visibility probe). On the 6.8 veth run, all five sweeps showed 0 byte divergences and 0 verdict divergences. The actions seen — identical on both backends — were: &lt;code&gt;pass_drop&lt;/code&gt; PASS, &lt;code&gt;metadata_test&lt;/code&gt; DROP, &lt;code&gt;vlan&lt;/code&gt; PASS, &lt;code&gt;l3_modify&lt;/code&gt; PASS, &lt;code&gt;redirect&lt;/code&gt; REDIRECT. The context axis stays unmeasured until the bare-metal follow-up.&lt;/p&gt;

&lt;p&gt;Example manifest excerpt (&lt;code&gt;prog_pass_drop&lt;/code&gt;, smoke gate):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"profile"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"virtio_vm"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"kernel"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"6.8.0-134-generic"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"backends"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"native"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"generic"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"corpus_sha256"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"…"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"program_sha256"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"…"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"divergence_count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"verdict_divergence_count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"cases"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"test_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;40961&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"fingerprints"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"native"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"a1b2c3…"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"generic"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"a1b2c3…"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"equivalent"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"verdict"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"native"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PASS"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"generic"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PASS"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"verdict_match"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(&lt;code&gt;test_id&lt;/code&gt; 40961 = 0xA001.) Full manifests for all five virtio programs are in the repository.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run it yourself
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/kazuru-chidumbwe/xdp-backend-equiv-harness" rel="noopener noreferrer"&gt;https://github.com/kazuru-chidumbwe/xdp-backend-equiv-harness&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/kazuru-chidumbwe/xdp-backend-equiv-harness.git
&lt;span class="nb"&gt;cd &lt;/span&gt;xdp-backend-equiv-harness
git checkout blog-x01-2026-07

&lt;span class="nb"&gt;sudo &lt;/span&gt;apt-get &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; clang llvm libbpf-dev python3-scapy xdp-tools make &lt;span class="se"&gt;\&lt;/span&gt;
  linux-tools-common linux-headers-&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;uname&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="c"&gt;# xdpdump ships in xdp-tools (Ubuntu/Debian). If missing: build from https://github.com/xdp-project/xdp-tools&lt;/span&gt;

bash scripts/smoke.sh
bash scripts/comparator-sensitivity.sh
bash scripts/sweep-all-virtio.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Troubleshooting — &lt;code&gt;smoke.sh&lt;/code&gt; exits non-zero when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;xdpdump&lt;/code&gt; or &lt;code&gt;clang&lt;/code&gt; is missing;&lt;/li&gt;
&lt;li&gt;topology cannot create the veth/netns pair (usually a stale &lt;code&gt;veth-a&lt;/code&gt; — rerun &lt;code&gt;sudo make topology&lt;/code&gt;);&lt;/li&gt;
&lt;li&gt;fewer than 11 paired test IDs land in the manifest;&lt;/li&gt;
&lt;li&gt;the sweep or comparator fails, including a verdict mismatch.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you see &lt;code&gt;Native and generic XDP can't be active at the same time&lt;/code&gt;, make sure both &lt;code&gt;xdp off&lt;/code&gt; and &lt;code&gt;xdpgeneric off&lt;/code&gt; ran between backend captures (current &lt;code&gt;harness/sweep.sh&lt;/code&gt; handles this). Use &lt;code&gt;sudo&lt;/code&gt; where shown; kernel 6.8+ matches the tagged lab gate.&lt;/p&gt;

&lt;p&gt;Outputs: &lt;code&gt;manifests/run_manifest_virtio_vm_*.json&lt;/code&gt;.&lt;/p&gt;




&lt;p&gt;Further reading: &lt;a href="https://github.com/xdp-project/xdp-tools" rel="noopener noreferrer"&gt;xdp-tools / xdpdump&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Synthetic lab traffic only. No production traffic.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>linux</category>
      <category>networking</category>
      <category>ebpf</category>
      <category>security</category>
    </item>
    <item>
      <title>Differential Testing Revealed What Conformance Testing Missed: A Case Study with Open-Source eMRTD Libraries</title>
      <dc:creator>Kazuru</dc:creator>
      <pubDate>Tue, 07 Jul 2026 21:06:27 +0000</pubDate>
      <link>https://dev.to/kazuru_73322ef9a7d6ed2b18/differential-testing-revealed-what-conformance-testing-missed-a-case-study-with-open-source-emrtd-1nie</link>
      <guid>https://dev.to/kazuru_73322ef9a7d6ed2b18/differential-testing-revealed-what-conformance-testing-missed-a-case-study-with-open-source-emrtd-1nie</guid>
      <description>&lt;p&gt;Conformance suites tell us whether a library passes its own tests. They do not tell us whether two independent implementations make the same decision when presented with identical protocol traffic. &lt;a href="https://www.icao.int/publications/doc-series/doc-9303" rel="noopener noreferrer"&gt;ICAO Doc 9303&lt;/a&gt; defines how an e-passport should behave. But the open-source library you choose determines what your application actually experiences. Libraries pass unit tests. Integrators pass interoperability tests. At some point, we stopped asking a much simpler question: if two independent implementations receive exactly the same protocol traffic, will they make the same security decision?&lt;/p&gt;

&lt;p&gt;We set out to find an answer. Our approach was a controlled experiment built around a deterministic synthetic protocol profile, with no passport on our desks, no NFC reader, and no RF variables to chase. We replayed the same protocol exchange against two widely used wire-tier eMRTD readers: &lt;a href="https://github.com/gmrtd/gmrtd" rel="noopener noreferrer"&gt;gmrtd&lt;/a&gt; in Go and &lt;a href="https://jmrtd.org" rel="noopener noreferrer"&gt;JMRTD&lt;/a&gt; in Java.&lt;/p&gt;

&lt;p&gt;What we found was not dramatic, but it was revealing. PACE failed during the initial negotiation. BAC then completed successfully. For an application that merely checks whether a secure session exists, there was no explicit indication that a protocol fallback had occurred. Both libraries produced the same observable behaviour under the same profile.&lt;/p&gt;

&lt;p&gt;The experiment was also entirely reproducible. We could rerun it later the same afternoon and obtain the same result, backed by the same traces and run metadata.&lt;/p&gt;

&lt;h2&gt;
  
  
  BAC, PACE, and why we bothered with differential testing
&lt;/h2&gt;

&lt;p&gt;If you have shipped TLS, you already know what negotiation failure feels like at the integration layer. eMRTD access control is the same shape of problem on a contactless chip.&lt;/p&gt;

&lt;p&gt;BAC (Basic Access Control) is the older path: session keys from MRZ data. PACE (Password Authenticated Connection Establishment) is what newer documents advertise in EF.CardAccess before authentication, and what auditors increasingly expect terminals to prefer when it is on offer. Most chips still speak both. That is normal.&lt;/p&gt;

&lt;p&gt;The interesting part is reader behaviour when PACE fails. Does the stack stop and report? Or does it try BAC and hand your application something that looks like a clean open session?&lt;/p&gt;

&lt;p&gt;Conformance testing asks whether each library passes its own checklist. Differential testing fixes the chip behaviour and asks what each library tells the application. We are interested in the second question.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we were trying to learn
&lt;/h2&gt;

&lt;p&gt;The hypothesis, in plain language: if two independent implementations see identical protocol traffic, should a typical application caller see the same outcome?&lt;/p&gt;

&lt;p&gt;We deliberately kept the first cut small. Two libraries, not five. One synthetic downgrade: PACE advertised, first PACE APDU returns &lt;code&gt;6FFF&lt;/code&gt;, BAC mutual authentication still works. One smoke run per library. This essay is one concrete case per stack, not a prevalence study.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the experiment is set up
&lt;/h2&gt;

&lt;p&gt;The chip is a JSON profile. A simulator answers APDUs from that profile in-process, with no silicon and no RF for this test. Each driver calls the real library negotiation path against the same byte stream. Whatever comes back, whether return value, session fields, or APDU log, gets written to a trace file.&lt;/p&gt;

&lt;p&gt;The profile we used is called &lt;code&gt;pace-then-bac-downgrade&lt;/code&gt;. The important fields look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"TC-AC-01"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"condition"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"pace_fail_then_bac"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"injection"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"pace_fail_on"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"first_pace_apdu"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"pace_sw"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"6FFF"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;EF.CardAccess advertises PACE before authentication (our profile models this via &lt;code&gt;card_access_hex&lt;/code&gt;). The first PACE step dies with &lt;code&gt;6FFF&lt;/code&gt;. BAC still completes. That is the entire adversarial condition.&lt;/p&gt;

&lt;p&gt;We also score each run for whether a naive integration would notice the downgrade. Score 0 means the failure never has to surface to code that only checks "did it open?" Score 2 means the caller cannot proceed without handling the failure. For this post, score 0 is the whole story.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running the experiment
&lt;/h2&gt;

&lt;p&gt;The test case is TC-AC-01. We performed one execution per library. We cared about the JSON trace: &lt;code&gt;run_id&lt;/code&gt;, the APDU arrays, &lt;code&gt;bac_success&lt;/code&gt;, &lt;code&gt;observability_score&lt;/code&gt;, and whatever error surface each library actually exposes.&lt;/p&gt;

&lt;p&gt;On the wire, both readers should tell the same chip story. MSE:Set AT starts PACE; the chip returns &lt;code&gt;6FFF&lt;/code&gt;; GET CHALLENGE and EXTERNAL AUTHENTICATE still finish with &lt;code&gt;9000&lt;/code&gt;. The BAC ciphertexts differ run to run because each library generates its own random numbers. The failure point and the fallback should not differ.&lt;/p&gt;

&lt;p&gt;Each driver writes &lt;code&gt;logs/&amp;lt;run_id&amp;gt;.json&lt;/code&gt;. At pin &lt;code&gt;blog-b10-2026-07&lt;/code&gt;, &lt;code&gt;run_id&lt;/code&gt; uses microsecond UTC time plus a monotonic sequence suffix — pattern &lt;code&gt;TC-AC-01-{library}-YYYYMMDDTHHMMSS.microsecondsZ-NNNNNN&lt;/code&gt; (for example &lt;code&gt;TC-AC-01-gmrtd-20260707T135005.482913Z-000001&lt;/code&gt;). We captured smoke traces the afternoon we drafted this post; a colleague reran &lt;code&gt;make smoke&lt;/code&gt; on a fresh lab clone at the same pin and got the same outcome (&lt;code&gt;observability_score: 0&lt;/code&gt; on both drivers) with different &lt;code&gt;run_id&lt;/code&gt; values, as expected.&lt;/p&gt;

&lt;h2&gt;
  
  
  What showed up on the wire
&lt;/h2&gt;

&lt;h3&gt;
  
  
  gmrtd
&lt;/h3&gt;

&lt;p&gt;gmrtd records the PACE failure on the session object. It does not fail the call your service is likely to treat as the success boundary.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;docEx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;apduLog&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;reader&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ReadDocument&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;atr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ats&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Printf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"ReadDocument err: %v&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c"&gt;// &amp;gt;&amp;gt;&amp;gt; nil&lt;/span&gt;

&lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Printf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"PACE err: %v&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;docEx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Session&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PaceErr&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c"&gt;// &amp;gt;&amp;gt;&amp;gt; [DoPACE] doApduMseSetAT error: ... MSE:Set AT failed (Status:6fff)&lt;/span&gt;

&lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Printf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"BAC success: %v&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;docEx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Session&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BacResult&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;docEx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Session&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BacResult&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Success&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c"&gt;// &amp;gt;&amp;gt;&amp;gt; true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;PaceErr&lt;/code&gt; is populated. The APDU log shows &lt;code&gt;6fff&lt;/code&gt; before the BAC commands. &lt;code&gt;err&lt;/code&gt; is nil. If your handler only checks the return value, you log a successful read after a failed PACE and move on.&lt;/p&gt;

&lt;p&gt;From our gmrtd smoke trace (&lt;code&gt;logs/TC-AC-01-gmrtd-*.json&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;C: 0022c1a412800a04007f0007020204020483010184010d
R: 6fff

C: 0084000008
R: 4608f919887022129000

C: 0082000028d7b3b9d55cb313556804d6afedd55db24f0e6389dc7403dbd4d4be510d784711fcafd7dfd68a387628
R: 46b9342a41396cd7d74fff620ba529aa76ed1d79623747d5383fd073b453010e3a6c7e2c59e3ea3e9000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Observability score: 0.&lt;/p&gt;

&lt;h3&gt;
  
  
  JMRTD
&lt;/h3&gt;

&lt;p&gt;JMRTD is louder at the library boundary. It throws &lt;code&gt;PACEException&lt;/code&gt; on &lt;code&gt;6FFF&lt;/code&gt;. Production glue is often quieter.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;PACEException&lt;/span&gt; &lt;span class="n"&gt;paceFailure&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;service&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;doPACE&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bacKey&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;paceInfo&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getObjectIdentifier&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt;
        &lt;span class="nc"&gt;PACEInfo&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toParameterSpec&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;paceInfo&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getParameterId&lt;/span&gt;&lt;span class="o"&gt;()));&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;PACEException&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;paceFailure&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// SW = 0x6fff&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;service&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;sendSelectApplet&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;service&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;doBAC&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bacKey&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We have seen this pattern enough times to treat it as the default risk, not a corner case: catch, maybe log at DEBUG, call &lt;code&gt;doBAC()&lt;/code&gt;, return &lt;code&gt;authenticated: true&lt;/code&gt;. The experiment models that wrapper on purpose.&lt;/p&gt;

&lt;p&gt;From our JMRTD smoke trace (&lt;code&gt;logs/TC-AC-01-jmrtd-*.json&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;C: 0022c1a40f800a04007f00070202040204830101
R: 6fff

C: 00a4040c07a0000002471001
R: 9000

C: 0084000008
R: 4608f919887022129000

C: 008200002892e5c592d2995c4f76a0e0b7f71e8d915f28e27a5a6bc11aa5706ddc781e05cbd3f6534575ebc19b28
R: 46b9342a41396cd798d2cc8b293b4fb78abfd26d4ef5bd5bd8475bf0f290ed59325904a018b7d5419000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same chip. Different API shape. Same gap if the application only asks whether the session opened. Observability score: 0 with the catch-and-continue wrapper.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why we think integrators should care
&lt;/h2&gt;

&lt;p&gt;When PACE fails and BAC succeeds, the stack has accepted a weaker mechanism than the chip advertised. Your middleware may record &lt;code&gt;authenticated: true&lt;/code&gt; and never store that PACE was attempted and rejected. SIEM rules built on session success will not see it. Policy that says "use PACE when EF.CardAccess offers it" cannot enforce what your API never surfaces.&lt;/p&gt;

&lt;p&gt;If you have ever searched a vendor codebase for &lt;code&gt;doBAC&lt;/code&gt; after a failed &lt;code&gt;doPACE&lt;/code&gt;, you know the feeling. The README says the library supports PACE. Your wrapper might still treat BAC success as mission accomplished. Audit the tag you pinned, not the marketing page.&lt;/p&gt;

&lt;h2&gt;
  
  
  A fix that is boring on purpose
&lt;/h2&gt;

&lt;p&gt;The failure mode is implicit downgrade. The fix we care about is equally boring: do not fall back unless fallback is an explicit policy decision.&lt;/p&gt;

&lt;p&gt;On gmrtd that means surfacing &lt;code&gt;PaceErr&lt;/code&gt; on the return path, not burying it on the session struct. On JMRTD it means rethrowing &lt;code&gt;PACEException&lt;/code&gt; instead of catch-and-continue. In pseudocode the rule is simply: if PACE failed and policy does not allow BAC fallback, return an error; do not silently open BAC and call it success.&lt;/p&gt;

&lt;p&gt;We wired a thin middleware layer in the experiment repo to show the before and after. Baseline drivers score 0. Mitigated drivers force the caller to handle PACE failure. Nothing clever, just separating protocol outcome from transaction success.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same story elsewhere
&lt;/h2&gt;

&lt;p&gt;Passports are a convenient place to run this because the open source readers exist and the specification is public. The pattern is not passport-specific. Long-lived hardware with short-lived software on top shows up in PIV, national eID, payment terminals, anywhere a secure element outlives the middleware version you pinned last quarter. TLS cipher negotiation and SSH KEX fallback rhyme with the same question: which mechanism actually ran?&lt;/p&gt;

&lt;h2&gt;
  
  
  What this is not
&lt;/h2&gt;

&lt;p&gt;Synthetic traces only, with no RF lab, no real chip silicon, no live PKD or CRL infrastructure. Two named libraries at one deterministic condition, smoke depth, pinned reproduction commit. We are describing behaviour divergence, and we are not claiming a deployed exploit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run it yourself
&lt;/h2&gt;

&lt;p&gt;This is the first place in the essay we point at the codebase.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/kazuru-chidumbwe/emrtd-differential-harness" rel="noopener noreferrer"&gt;https://github.com/kazuru-chidumbwe/emrtd-differential-harness&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Checkout tag &lt;code&gt;blog-b10-2026-07&lt;/code&gt; (commit &lt;code&gt;ef15b10&lt;/code&gt;, 7 July 2026). That pin is what this post describes; &lt;code&gt;main&lt;/code&gt; may move on without changing the essay's claims.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/kazuru-chidumbwe/emrtd-differential-harness.git
&lt;span class="nb"&gt;cd &lt;/span&gt;emrtd-differential-harness
git checkout blog-b10-2026-07
bash scripts/bootstrap-vendor.sh
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;GOTOOLCHAIN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;auto
make smoke
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You should get JSON traces with &lt;code&gt;observability_score: 0&lt;/code&gt; on both baseline drivers. Look in &lt;code&gt;logs/&lt;/code&gt; — the &lt;code&gt;run_id&lt;/code&gt; field inside each file matches its filename. On Ubuntu 24.04 with Go 1.25+ (&lt;code&gt;GOTOOLCHAIN=auto&lt;/code&gt;), Java 17, and Maven, &lt;code&gt;make smoke&lt;/code&gt; finished in under a minute on a warmed lab VM where vendors were already bootstrapped. A cold first clone pays extra time for &lt;code&gt;bootstrap-vendor.sh&lt;/code&gt; and compiling JMRTD from vendor source; budget several minutes, not seconds.&lt;/p&gt;

&lt;p&gt;One practical wrinkle: the &lt;code&gt;jmrtd-0.5.2&lt;/code&gt; artifact on Maven Central is empty for our build. The bootstrap script compiles JMRTD from vendor source. Budget a few extra minutes the first time.&lt;/p&gt;

&lt;p&gt;MIT license. If you extend the work, cite the &lt;code&gt;run_id&lt;/code&gt; from your trace.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;Two mainstream OSS passport readers. One synthetic downgrade. Both complete BAC after PACE fails. Both stay silent to a naive caller.&lt;/p&gt;

&lt;p&gt;If that matters in your stack, rerun the pin before you ship. The traces are public. The check takes minutes. We are not asking you to trust our summary — we are asking you to run the same profile and read your own &lt;code&gt;run_id&lt;/code&gt;.&lt;/p&gt;




&lt;p&gt;Further reading: &lt;a href="https://www.icao.int/publications/doc-series/doc-9303" rel="noopener noreferrer"&gt;ICAO Doc 9303&lt;/a&gt; · &lt;a href="https://github.com/gmrtd/gmrtd" rel="noopener noreferrer"&gt;gmrtd&lt;/a&gt; · &lt;a href="https://jmrtd.org" rel="noopener noreferrer"&gt;JMRTD&lt;/a&gt; · &lt;a href="https://doi.org/10.1145/2825026" rel="noopener noreferrer"&gt;Avoine et al., ePassport protocol survey&lt;/a&gt; · &lt;a href="https://www.bsi.bund.de/dok/TR-03110-en" rel="noopener noreferrer"&gt;BSI TR-03110&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Synthetic test environment only. No physical travel document was used. Results apply to gmrtd and JMRTD at TC-AC-01 smoke depth, tag &lt;code&gt;blog-b10-2026-07&lt;/code&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>opensource</category>
      <category>cryptography</category>
      <category>testing</category>
    </item>
  </channel>
</rss>
