skip to content

On a dual-stack server, why does the tcpdump filter `tcp[tcpflags] & (tcp-syn|tcp-ack) == tcp-syn` show IPv4 SYNs but no IPv6 SYNs, and how do you catch both?

level: seniorimportance: should knowfreq 13%

answer

  1. accessor guarded by EtherType
  2. IPv4 only, silently
  3. index from the IPv6 header
  4. extension headers move the offset

basics

~20 s

The tcp[...] accessor only matches IPv4: the compiled code first checks for EtherType 0x800. For IPv6, index from the IPv6 header, ip6[6] == 6 and ip6[53] & 0x12 == 0x02, which holds only when no extension header precedes TCP.

solid answer

~40 s

In libpcap's packet data accessors, `tcp[...]`, `udp[...]`, `icmp[...]` and the other upper-layer forms **do not match IPv6 packets**; the man page lists it as a known limitation. The compiled program checks for an IPv4 EtherType, skips non-first fragments and only then indexes the TCP header, so IPv6 SYNs fail the first test without any warning. For IPv6, index from the IPv6 header instead: `ip6[6] == 6` checks that the Next Header field says TCP, and `ip6[53]` is byte 13 of a TCP header that starts right after the 40-byte IPv6 header. The dual-stack filter is `'(tcp[tcpflags] & (tcp-syn|tcp-ack) == tcp-syn) or (ip6[6] == 6 and ip6[53] & 0x12 == 0x02)'`. It misses IPv6 SYNs behind extension headers; `ip6 protochain 6` chases the chain but cannot be optimized and filters in user space.

go deeper

for a junior

Remember that tcp[...] byte tests only see IPv4 packets, while host and port primitives cover IPv6 as well.

for a middle

Explain the IPv4 EtherType guard in the compiled code and how to write the ip6[6] and ip6[53] equivalent for IPv6.

for a senior

Treat a silent zero as a capture-side question first: check the filter with tcpdump -d before reporting that a family or a client is absent.

for a principal

On dual-stack estates, make IPv6 coverage an explicit requirement of shared incident filters so investigations do not systematically miss one family.

## What the man page says libpcap's **pcap-filter** documentation is explicit: in the current implementation of packet data accessors, `tcp`, `udp`, `icmp`, `igmp`, `pim`, `vrrp`, `carp`, `sctp` and `igrp` **do not match IPv6 packets**, and `icmp6` assumes no extension headers. Its BUGS section repeats it: an arithmetic expression against a transport-layer header, like `tcp[0]`, only looks at IPv4 packets. This holds at libpcap 1.11.0, the release paired here with tcpdump 4.99. The expression still compiles, still runs and still prints IPv4 matches, so nothing tells you that half of the traffic is invisible. That is what makes it an incident trap: "no IPv6 SYNs" looks like a network finding when it is a filter artefact at the capture point. ## Why it happens: the compiled guard Compiling a `tcp[...]` test with `tcpdump -d` on Ethernet shows the guard before any TCP byte is read: ``` (000) ldh [12] (001) jeq #0x800 jt 2 jf 10 (002) ldb [23] (003) jeq #0x6 jt 4 jf 10 (004) ldh [20] (005) jset #0x1fff jt 10 jf 6 (006) ldxb 4*([14]&0xf) ``` That is the listing libpcap's own test suite expects for `tcp[0xab] == 0xcd`. Instruction 001 accepts only EtherType 0x800 (IPv4); 003 checks the IPv4 protocol field for TCP; 005 rejects non-first fragments; 006 loads the IPv4 header length so the TCP offset can be computed. An IPv6 frame (EtherType 0x86dd) jumps straight to the reject line. Contrast that with a `port` primitive: `tcp port 25` compiles to a program with a `jeq #0x86dd` branch as well as the IPv4 branch. The `port` primitive handles both families; byte accessors on transport headers do not. ## Writing the IPv6 half The `ip6` accessor indexes from the start of the IPv6 header, which has a fixed 40-byte length: | Expression | Reads | Why | |---|---|---| | `ip6[6]` | Next Header field | byte 6 of the IPv6 header | | `ip6[6] == 6` | TCP follows immediately | protocol number 6 | | `ip6[40 + 13]` = `ip6[53]` | TCP flags byte | 40-byte IPv6 header plus byte 13 of TCP | So IPv6 initial SYNs are `ip6[6] == 6 and ip6[53] & 0x12 == 0x02`, and the dual-stack filter is: ``` (tcp[tcpflags] & (tcp-syn|tcp-ack) == tcp-syn) or (ip6[6] == 6 and ip6[53] & 0x12 == 0x02) ``` Two details matter: - Use `ip6[6] == 6`, not `ip6 proto 6`. `ip6 proto` also matches a packet whose Next Header is 44 (Fragment) followed by TCP, and in that case byte 53 is not the TCP flags byte. - The fixed offset is only right when **no extension header** sits between IPv6 and TCP. That is the common case for TCP, but hop-by-hop, routing or fragment headers break it. ## When extension headers matter `ip6 protochain 6` matches any IPv6 packet with TCP somewhere in its header chain. The man page warns about the cost: the BPF code it emits is complex, **cannot be optimized** by the BPF optimizer and is **not supported by the filter engines in the kernel**, so filtering happens in user space and more packets may be dropped on a busy link. It also tells you only that TCP is present, not where, so it cannot be combined with a fixed `ip6[...]` flags offset to find SYNs behind extension headers. In practice: 1. Use the fixed-offset form for live triage on busy links. 2. If extension headers are suspected, capture `ip6 protochain 6` (or plain `ip6 and host 2001:db8::10`) to a file on a quieter scope and analyse flags afterwards; a `port` primitive would miss those packets too. The same caveat applies to other primitives: `port` and `portrange` on IPv6 imply no extension headers, and `icmp6[icmp6type]` does too. ## Verifying before you trust it - Compile the expression with `tcpdump -d` and look for a `jeq #0x86dd` branch. If there is none, the filter cannot see IPv6. - Run it briefly while a known IPv6 client connects to the service, and confirm the SYN appears. - Report findings by capture point: "no IPv6 SYNs matched this filter on this interface" is a statement about the capture; "clients are not connecting over IPv6" needs a filter that could have seen them.

  • Why use `ip6[6] == 6` rather than `ip6 proto 6` in front of a fixed `ip6[53]` flags test?
    `ip6 proto 6` also matches IPv6 packets whose Next Header is a Fragment header followed by TCP. In those packets the TCP header does not start at byte 40, so `ip6[53]` reads the wrong byte. `ip6[6] == 6` requires TCP immediately after the fixed header, which is exactly when the offset is valid.
  • How can you prove from tcpdump itself that a filter cannot match IPv6?
    Compile it with `tcpdump -d` and read the first branches. A `tcp[...]` test starts with `ldh [12]` and `jeq #0x800`, and every other path leads to `ret #0`. A filter that handles IPv6 has a `jeq #0x86dd` branch; `tcp port 25`, for example, compiles with both.

saying these in an interview costs you the question

  • Concludes no IPv6 clients connect because the tcp[...] filter showed none
  • Assumes tcp[13] is computed from whichever IP header is present
  • Uses ip6 proto 6 to guard a fixed ip6[53] offset
  • Adds ip6 protochain to a busy live capture without considering drops
  • Believes the compiler warns when an accessor cannot match IPv6