srdusr
aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsrdusr <[email protected]>2025-06-26 14:59:00 +0200
committersrdusr <[email protected]>2025-06-26 14:59:00 +0200
commit9046e6a10fd2d987cf6f6dc601ed3a75d286793f (patch)
tree2f28a977e4bf8143a4a8468bc474bbab725b950c
parentd9bedcec1bce8d15de6403377702d51d1bcb862f (diff)
downloadpacketeer-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.md24
-rw-r--r--include/packeteer/net/ethernet.hpp37
-rw-r--r--include/packeteer/summarize.hpp25
-rw-r--r--tests/test_net.cpp57
-rw-r--r--tests/test_summarize.cpp16
5 files changed, 154 insertions, 5 deletions
diff --git a/PLAN.md b/PLAN.md
index c384e20..a9a8da6 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -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);