diff options
| author | srdusr <[email protected]> | 2025-06-26 14:59:00 +0200 |
|---|---|---|
| committer | srdusr <[email protected]> | 2025-06-26 14:59:00 +0200 |
| commit | 9046e6a10fd2d987cf6f6dc601ed3a75d286793f (patch) | |
| tree | 2f28a977e4bf8143a4a8468bc474bbab725b950c | |
| parent | d9bedcec1bce8d15de6403377702d51d1bcb862f (diff) | |
| download | packeteer-9046e6a10fd2d987cf6f6dc601ed3a75d286793f.tar.gz packeteer-9046e6a10fd2d987cf6f6dc601ed3a75d286793f.zip | |
Unwrap VLAN (802.1Q/802.1ad) tags before protocol dispatch
The single highest-value coverage gap so far, and structural rather
than a new dissector: a VLAN-tagged frame's ethertype reads as 0x8100,
so every existing decoder - ARP, IPv4, IPv6, and everything built on
top of them - was completely invisible on any tagged network.
walk_vlan_tags() (ethernet.hpp) is composable and separate from
parse_ethernet(), the same relationship walk_ipv6_extension_headers()
has to parse_ipv6(): the base parse stays an unconditional fixed-header
decode, and this is what a caller reaches for when it needs the real
protocol underneath. Handles stacked (QinQ) tags, bounded at 4 levels
against a corrupt/hostile frame claiming an unbounded chain.
Live-verified with genuine kernel-tagged frames, not synthetic bytes:
a dummy0 interface with an 802.1Q dummy0.42 sub-interface (VLAN 42),
captured on the parent while pinging out the sub-interface. Both
interfaces and the kernel modules they pulled in were torn down
afterward.
| -rw-r--r-- | PLAN.md | 24 | ||||
| -rw-r--r-- | include/packeteer/net/ethernet.hpp | 37 | ||||
| -rw-r--r-- | include/packeteer/summarize.hpp | 25 | ||||
| -rw-r--r-- | tests/test_net.cpp | 57 | ||||
| -rw-r--r-- | tests/test_summarize.cpp | 16 |
5 files changed, 154 insertions, 5 deletions
@@ -437,3 +437,27 @@ None currently open. decoded; fixed by actually binding the "server" send to port 67 (as real DHCP servers do), which then correctly decoded "DHCP OFFER yiaddr=192.168.1.50" on top of "DHCP DISCOVER" for the request. +- VLAN (802.1Q/802.1ad) tag unwrapping: walk_vlan_tags() (ethernet.hpp) + was the single highest-value gap found while pushing toward broader + protocol coverage - not a new protocol dissector but a structural + fix, since a tagged frame's ethertype reads as 0x8100 and every + existing decoder (ARP, IPv4, IPv6, and everything built on top of + them) was completely invisible on any VLAN-tagged network before + this. Composable and separate from parse_ethernet() the same way + walk_ipv6_extension_headers() is separate from parse_ipv6() - the + base parse stays an unconditional fixed-header decode; this is what + a caller reaches for when it needs the real protocol underneath. + Handles stacked (QinQ, 802.1ad) tags, bounded at 4 levels so a + corrupt/hostile frame claiming an unbounded tag chain can't spin -- + real QinQ stacks are 2 deep at most. summarize_packet() still shows + the literal on-the-wire outer ethertype (0x8100) plus a vlan=N (or + vlan=N,M for stacked) annotation, then dispatches ARP/IPv4/IPv6 on + the real ethertype underneath. Live-verified with genuine + kernel-tagged frames, not synthetic bytes: created a `dummy0` + interface with an 802.1Q `dummy0.42` sub-interface (VLAN 42), + captured on the parent while pinging out the sub-interface, and got + real 802.1Q-tagged ICMP echo requests back - "ethertype=0x8100 + vlan=42 | IPv4 10.99.99.1 -> 10.99.99.2 ... | ICMP Echo Request" + correctly unwrapped. Both virtual interfaces and the dummy/8021q + kernel modules they pulled in were torn down afterward, restoring + the machine to its prior state. diff --git a/include/packeteer/net/ethernet.hpp b/include/packeteer/net/ethernet.hpp index 5b851bc..77d2927 100644 --- a/include/packeteer/net/ethernet.hpp +++ b/include/packeteer/net/ethernet.hpp @@ -5,6 +5,7 @@ #include <cstdint> #include <optional> #include <span> +#include <vector> #include "packeteer/byteio.hpp" @@ -14,6 +15,8 @@ inline constexpr std::size_t kEthernetHeaderLen = 14; inline constexpr std::uint16_t kEthertypeIPv4 = 0x0800; inline constexpr std::uint16_t kEthertypeIPv6 = 0x86DD; inline constexpr std::uint16_t kEthertypeArp = 0x0806; +inline constexpr std::uint16_t kEthertypeVlan = 0x8100; // IEEE 802.1Q +inline constexpr std::uint16_t kEthertypeVlanQinQ = 0x88A8; // IEEE 802.1ad, stacked tags struct MacAddress { std::array<unsigned char, 6> bytes; @@ -41,4 +44,38 @@ inline std::optional<EthernetFrame> parse_ethernet(std::span<const unsigned char return EthernetFrame{header, bytes.subspan(kEthernetHeaderLen)}; } +struct VlanWalkResult { + std::vector<std::uint16_t> vlan_ids; // outer to inner; usually 0 or 1, 2 for QinQ + std::uint16_t ethertype; // the real ethertype once every tag is stripped + std::span<const unsigned char> payload; +}; + +// A VLAN-tagged frame inserts a 4-byte tag (2-byte TPID + 2-byte TCI, +// the low 12 bits of which are the VLAN ID) right where the ethertype +// field would otherwise be, pushing the real ethertype 4 bytes deeper +// - parse_ethernet() alone has no way to know this, since it always +// reads a fixed 14-byte header and hands back whatever follows as +// "payload" regardless of whether that's really IP/ARP or another +// tag. Composable and separate from parse_ethernet() the same way +// walk_ipv6_extension_headers() is separate from parse_ipv6(): the +// base parse stays an unconditional fixed-header decode, and this +// walk is what a caller uses when it actually needs the protocol +// underneath. Bounded at 4 tags so a corrupt/hostile frame claiming +// tag-of-a-tag-of-a-tag indefinitely can't spin - real QinQ stacks +// are 2 deep at most. +inline VlanWalkResult walk_vlan_tags(std::uint16_t ethertype, + std::span<const unsigned char> payload) { + VlanWalkResult result{{}, ethertype, payload}; + constexpr int kMaxTags = 4; + for (int i = 0; i < kMaxTags; ++i) { + if (result.ethertype != kEthertypeVlan && result.ethertype != kEthertypeVlanQinQ) break; + if (result.payload.size() < 4) break; // truncated tag + std::uint16_t tci = read_be16(result.payload, 0); + result.vlan_ids.push_back(tci & 0x0FFF); + result.ethertype = read_be16(result.payload, 2); + result.payload = result.payload.subspan(4); + } + return result; +} + } // namespace packeteer::net diff --git a/include/packeteer/summarize.hpp b/include/packeteer/summarize.hpp index 4143e44..302b380 100644 --- a/include/packeteer/summarize.hpp +++ b/include/packeteer/summarize.hpp @@ -220,16 +220,31 @@ inline std::string summarize_packet(std::span<const unsigned char> bytes, int da std::snprintf(eth_buf, sizeof(eth_buf), " ethertype=0x%04x", eth->header.ethertype); out += eth_buf; - if (eth->header.ethertype == net::kEthertypeArp) { - if (auto arp = net::parse_arp(eth->payload)) out += " | " + arp_summary(*arp); + // Unwraps any 802.1Q/802.1ad VLAN tags between the Ethernet + // header and the real protocol - without this, every tagged + // frame's actual ethertype reads as 0x8100 and everything + // below (ARP/IPv4/IPv6 and everything built on them) would be + // invisible on any VLAN-tagged network. The line still shows + // the literal on-the-wire outer ethertype above; dispatch from + // here on uses the walked (real) one. + auto vlan = net::walk_vlan_tags(eth->header.ethertype, eth->payload); + if (!vlan.vlan_ids.empty()) { + out += " vlan="; + for (std::size_t i = 0; i < vlan.vlan_ids.size(); ++i) { + if (i > 0) out += ","; + out += std::to_string(vlan.vlan_ids[i]); + } + } + + if (vlan.ethertype == net::kEthertypeArp) { + if (auto arp = net::parse_arp(vlan.payload)) out += " | " + arp_summary(*arp); return out; } - if (eth->header.ethertype != net::kEthertypeIPv4 && - eth->header.ethertype != net::kEthertypeIPv6) { + if (vlan.ethertype != net::kEthertypeIPv4 && vlan.ethertype != net::kEthertypeIPv6) { return out; } - ip_bytes = eth->payload; + ip_bytes = vlan.payload; } if (ip_bytes.empty()) { diff --git a/tests/test_net.cpp b/tests/test_net.cpp index 19667da..2702758 100644 --- a/tests/test_net.cpp +++ b/tests/test_net.cpp @@ -30,6 +30,63 @@ TEST_CASE("parse_ethernet rejects a frame shorter than the header") { CHECK_FALSE(parse_ethernet(bytes).has_value()); } +TEST_CASE("walk_vlan_tags passes an untagged ethertype through unchanged") { + std::vector<unsigned char> payload = {0xDE, 0xAD, 0xBE, 0xEF}; + auto result = walk_vlan_tags(kEthertypeIPv4, payload); + CHECK(result.vlan_ids.empty()); + CHECK(result.ethertype == kEthertypeIPv4); + REQUIRE(result.payload.size() == 4); + CHECK(result.payload[0] == 0xDE); +} + +TEST_CASE("walk_vlan_tags unwraps a single 802.1Q tag") { + std::vector<unsigned char> payload = { + 0x00, 42, // TCI: VLAN ID 42 (PCP/DEI bits left zero) + 0x08, 0x00, // real ethertype: IPv4 + 0xDE, 0xAD, // real payload + }; + auto result = walk_vlan_tags(kEthertypeVlan, payload); + REQUIRE(result.vlan_ids.size() == 1); + CHECK(result.vlan_ids[0] == 42); + CHECK(result.ethertype == kEthertypeIPv4); + REQUIRE(result.payload.size() == 2); + CHECK(result.payload[0] == 0xDE); +} + +TEST_CASE("walk_vlan_tags unwraps a stacked QinQ pair, outer to inner") { + std::vector<unsigned char> payload = { + 0x00, 100, // outer TCI: VLAN 100 + 0x81, 0x00, // inner tag's TPID + 0x00, 42, // inner TCI: VLAN 42 + 0x08, 0x00, // real ethertype: IPv4 + 0xBE, 0xEF, + }; + auto result = walk_vlan_tags(kEthertypeVlanQinQ, payload); + REQUIRE(result.vlan_ids.size() == 2); + CHECK(result.vlan_ids[0] == 100); + CHECK(result.vlan_ids[1] == 42); + CHECK(result.ethertype == kEthertypeIPv4); +} + +TEST_CASE("walk_vlan_tags stops at a truncated tag rather than reading past it") { + std::vector<unsigned char> payload = {0x00, 42}; // 2 bytes: not a full 4-byte tag + auto result = walk_vlan_tags(kEthertypeVlan, payload); + CHECK(result.vlan_ids.empty()); + CHECK(result.ethertype == kEthertypeVlan); // unchanged: nothing was actually unwrapped +} + +TEST_CASE("walk_vlan_tags is bounded against a claimed unbounded tag chain") { + // Each 4-byte block claims "the next ethertype is another VLAN + // tag" - a corrupt/hostile frame that never actually reaches a + // real ethertype. Must stop, not loop indefinitely. + std::vector<unsigned char> payload; + for (int i = 0; i < 100; ++i) { + payload.insert(payload.end(), {0x00, 0x01, 0x81, 0x00}); + } + auto result = walk_vlan_tags(kEthertypeVlan, payload); + CHECK(result.vlan_ids.size() <= 4); +} + TEST_CASE("parse_ipv4 decodes header fields and leaves the right payload") { std::vector<unsigned char> bytes(20, 0); bytes[0] = 0x45; // version 4, IHL 5 (20-byte header, no options) diff --git a/tests/test_summarize.cpp b/tests/test_summarize.cpp index 27e0dd3..d7b223a 100644 --- a/tests/test_summarize.cpp +++ b/tests/test_summarize.cpp @@ -186,6 +186,22 @@ TEST_CASE("summarize_packet decodes an ARP request end to end") { "ARP who-has 10.0.0.2 tell 10.0.0.1 (aa:bb:cc:dd:ee:ff)"); } +TEST_CASE("summarize_packet unwraps a VLAN tag to reach the real ARP payload underneath") { + std::vector<unsigned char> bytes = { + 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, + 0x81, 0x00, // ethertype: 802.1Q + 0x00, 42, // TCI: VLAN 42 + 0x08, 0x06, // real ethertype: ARP + 0x00, 0x01, 0x08, 0x00, 0x06, 0x04, 0x00, 0x01, + 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, 10, 0, 0, 1, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 10, 0, 0, 2, + }; + auto line = packeteer::summarize_packet(bytes, DLT_EN10MB); + CHECK(line == + "ETH aa:bb:cc:dd:ee:ff -> 11:22:33:44:55:66 ethertype=0x8100 vlan=42 | " + "ARP who-has 10.0.0.2 tell 10.0.0.1 (aa:bb:cc:dd:ee:ff)"); +} + TEST_CASE("hex_dump_lines produces one line per 16 bytes, with the right byte count") { std::vector<unsigned char> bytes(20, 0); for (std::size_t i = 0; i < bytes.size(); ++i) bytes[i] = static_cast<unsigned char>(i); |