diff options
| -rw-r--r-- | PLAN.md | 42 | ||||
| -rw-r--r-- | include/packeteer/net/ipv4.hpp | 11 | ||||
| -rw-r--r-- | include/packeteer/net/ipv6.hpp | 53 | ||||
| -rw-r--r-- | include/packeteer/summarize.hpp | 28 | ||||
| -rw-r--r-- | tests/test_ipv6.cpp | 37 | ||||
| -rw-r--r-- | tests/test_net.cpp | 31 | ||||
| -rw-r--r-- | tests/test_summarize.cpp | 28 |
7 files changed, 224 insertions, 6 deletions
@@ -685,3 +685,45 @@ None currently open. real intermediate routers - this machine's own gateway, then real ISP infrastructure several hops out - all correctly showing "[this machine -> 8.8.8.8 proto=1]", matching traceroute's own hop output. +- IPv4/IPv6 fragmentation: a real correctness fix, not just added + visibility. Before this, a non-first fragment's payload - pure + continuation data, no TCP/UDP/ICMP header present at all - was + handed to the transport parsers unconditionally, which could + misread arbitrary payload bytes as port numbers, sequence numbers, + etc. and print a plausible-looking but entirely fake decode. IPv4 + gained identification/more_fragments/fragment_offset fields on + Ipv4Header; a nonzero fragment_offset now stops summarize_packet + before it ever reaches transport dispatch, reporting the fragment + itself instead. IPv6 expresses fragmentation as a Fragment + extension header instead of header fields, so the fix lives in + walk_ipv6_extension_headers(): it already walked past Fragment + headers, but never checked the offset before continuing on to + decode whatever followed as if it were a transport header -- + exactly the same bug, just reached through the extension-header + path instead of a header field. Fixed by having the walk stop + immediately (like the existing ESP hard-stop) whenever the offset is + nonzero, and reporting that stop via two new + Ipv6ExtensionWalkResult fields. + Caught and fixed a real bug in this fix while writing it, before it + ever ran: the first version computed a fragment's Identification + field as a loop-local variable, so it was silently discarded the + moment the walk continued past a *first* fragment's header (offset + zero) to keep decoding the real transport protocol underneath -- + every return path after that point reported no fragment id at all, + even though one genuinely applied. Fixed by hoisting it to a + variable that persists across loop iterations, the same category of + mistake (and the same fix) as this session's earlier IGMPv3 bug: + state that needs to survive past the specific branch that computed + it. Fuzzed afterward regardless (fuzz_ipv4, fuzz_ipv6, + fuzz_summarize, ~16.8M combined runs) - clean, no crashes. + Live-verified with genuine IPv4 fragmentation: a 4000-byte ping to + the real local gateway over the actual 1500-MTU wlp1s0 interface, + captured on the wire. Both directions fragmented into exactly 3 + pieces each; the first fragment decoded normally (ICMP Echo + Request/Reply, correct id/seq) with a "(fragmented, ...)" note, and + the two continuation fragments correctly showed only fragment + metadata - no fake ICMP decode attempted on their payload. IPv6 + fragmentation is unit-tested (including the exact byte pattern that + triggered the fragment-id bug above) but not live-verified: this + machine has no real global IPv6 connectivity to generate genuine + IPv6-fragmented traffic against, only link-local addresses. diff --git a/include/packeteer/net/ipv4.hpp b/include/packeteer/net/ipv4.hpp index c6cb656..bcc8fa6 100644 --- a/include/packeteer/net/ipv4.hpp +++ b/include/packeteer/net/ipv4.hpp @@ -27,6 +27,13 @@ struct Ipv4Header { std::uint8_t protocol; Ipv4Address src; Ipv4Address dst; + std::uint16_t identification; // ties fragments of the same original datagram together + bool more_fragments; + // In 8-byte units (RFC 791) - 0 means this is either an unfragmented + // packet or the *first* fragment, either way the one that actually + // carries the transport header. Nonzero means a later fragment: + // pure payload continuation, no TCP/UDP/ICMP header present at all. + std::uint16_t fragment_offset; }; struct Ipv4Packet { @@ -46,6 +53,10 @@ inline std::optional<Ipv4Packet> parse_ipv4(std::span<const unsigned char> bytes header.version = version; header.ihl = ihl; header.total_length = read_be16(bytes, 2); + header.identification = read_be16(bytes, 4); + std::uint16_t flags_and_offset = read_be16(bytes, 6); + header.more_fragments = (flags_and_offset & 0x2000) != 0; + header.fragment_offset = flags_and_offset & 0x1FFF; header.ttl = bytes[8]; header.protocol = bytes[9]; std::copy_n(bytes.begin() + 12, 4, header.src.bytes.begin()); diff --git a/include/packeteer/net/ipv6.hpp b/include/packeteer/net/ipv6.hpp index 8c048e8..d7ea0d5 100644 --- a/include/packeteer/net/ipv6.hpp +++ b/include/packeteer/net/ipv6.hpp @@ -70,6 +70,18 @@ struct Ipv6ExtensionWalkResult { std::uint8_t final_next_header; // a transport protocol, or an extension type we stopped at std::span<const unsigned char> payload; // bytes after every extension header walked bool stopped_at_esp; // true if ESP was hit - see walk_ipv6_extension_headers() + // True when a Fragment header (RFC 8200 4.5) was walked with a + // nonzero fragment offset - a later fragment, not the first. + // final_next_header still correctly names the eventual transport + // protocol, but `payload` here is pure continuation data with no + // TCP/UDP/ICMPv6 header in it at all; callers must not hand it to + // a transport parser. fragment_id is that header's 4-byte + // Identification field, set whenever a Fragment header was seen + // at all (first fragment or not) - useful for correlating + // fragments of the same original datagram even when this flag is + // false for the first one. + bool is_non_first_fragment; + std::optional<std::uint32_t> fragment_id; }; // Walks Hop-by-Hop, Routing, Destination Options, Fragment, and AH @@ -96,35 +108,64 @@ struct Ipv6ExtensionWalkResult { inline Ipv6ExtensionWalkResult walk_ipv6_extension_headers(std::uint8_t next_header, std::span<const unsigned char> payload) { constexpr int kMaxExtensionHeaders = 8; + // Persists across loop iterations, unlike a Fragment header's other + // fields: once a Fragment header is seen (even the first one, whose + // walk continues normally afterward), every return from here on + // should still report its Identification field, not lose it the + // moment the loop moves past that one header. + std::optional<std::uint32_t> seen_fragment_id; for (int i = 0; i < kMaxExtensionHeaders; ++i) { if (next_header == kNextHeaderEsp) { - return {next_header, payload, /*stopped_at_esp=*/true}; + return {next_header, payload, /*stopped_at_esp=*/true, false, seen_fragment_id}; } std::size_t ext_len; if (next_header == kNextHeaderFragment) { - if (payload.size() < 8) return {next_header, payload, false}; + if (payload.size() < 8) return {next_header, payload, false, false, seen_fragment_id}; ext_len = 8; } else if (next_header == kNextHeaderAh) { - if (payload.size() < 2) return {next_header, payload, false}; + if (payload.size() < 2) return {next_header, payload, false, false, seen_fragment_id}; ext_len = (static_cast<std::size_t>(payload[1]) + 2) * 4; } else if (next_header == kNextHeaderHopByHop || next_header == kNextHeaderRouting || next_header == kNextHeaderDestOptions) { - if (payload.size() < 2) return {next_header, payload, false}; + if (payload.size() < 2) return {next_header, payload, false, false, seen_fragment_id}; ext_len = (static_cast<std::size_t>(payload[1]) + 1) * 8; } else { break; // TCP/UDP/ICMPv6/anything else we don't chain through: stop here } - if (payload.size() < ext_len) return {next_header, payload, false}; // truncated: stop + if (payload.size() < ext_len) { + return {next_header, payload, false, false, seen_fragment_id}; // truncated: stop + } + + if (next_header == kNextHeaderFragment) { + // Fragment Offset (13 bits) + Res (2 bits) + M flag (1 bit) + // at bytes[2:4]; Identification (4 bytes) at bytes[4:8]. + std::uint16_t offset_res_m = read_be16(payload, 2); + std::uint16_t fragment_offset = (offset_res_m >> 3) & 0x1FFF; + seen_fragment_id = read_be32(payload, 4); + std::uint8_t this_next_header = payload[0]; + std::span<const unsigned char> remaining = payload.subspan(ext_len); + if (fragment_offset != 0) { + // A later fragment: `remaining` is pure continuation + // data, not a transport header, regardless of what + // this_next_header names. Stop here rather than + // walking (and definitely rather than letting a + // caller hand this to a transport parser). + return {this_next_header, remaining, false, true, seen_fragment_id}; + } + payload = remaining; + next_header = this_next_header; + continue; + } std::uint8_t this_next_header = payload[0]; payload = payload.subspan(ext_len); next_header = this_next_header; } - return {next_header, payload, false}; + return {next_header, payload, false, false, seen_fragment_id}; } // RFC 5952 canonical text form: lowercase hex, and the longest run of diff --git a/include/packeteer/summarize.hpp b/include/packeteer/summarize.hpp index 4126eb5..4bb2d24 100644 --- a/include/packeteer/summarize.hpp +++ b/include/packeteer/summarize.hpp @@ -319,9 +319,27 @@ inline std::string summarize_packet(std::span<const unsigned char> bytes, int da out += " | IPv4 (truncated)"; return out; } + bool is_fragment = ip->header.more_fragments || ip->header.fragment_offset != 0; + if (ip->header.fragment_offset != 0) { + // A later fragment: no TCP/UDP/ICMP header is present in + // this packet at all, only continuation payload - handing + // it to a transport parser would misread arbitrary payload + // bytes as port numbers/sequence numbers/etc. Report the + // fragment itself and stop, rather than decode garbage. + char buf[96]; + std::snprintf(buf, sizeof(buf), " | IPv4 %s -> %s ttl=%u proto=%u fragment id=%u offset=%u%s", + ipv4_to_string(ip->header.src).c_str(), ipv4_to_string(ip->header.dst).c_str(), + ip->header.ttl, ip->header.protocol, ip->header.identification, + ip->header.fragment_offset * 8, ip->header.more_fragments ? " (more)" : ""); + out += buf; + return out; + } out += summarize_transport_and_above({"IPv4", ipv4_to_string(ip->header.src), ipv4_to_string(ip->header.dst), ip->header.ttl, ip->header.protocol, ip->payload}); + if (is_fragment) { + out += " (fragmented, id=" + std::to_string(ip->header.identification) + ", more follow)"; + } } else if (version == 6) { auto ip6 = net::parse_ipv6(ip_bytes); if (!ip6) { @@ -341,9 +359,19 @@ inline std::string summarize_packet(std::span<const unsigned char> bytes, int da src_str.c_str(), dst_str.c_str(), ip6->header.hop_limit, net::kNextHeaderEsp); out += buf; + } else if (walked.is_non_first_fragment) { + char buf[128]; + std::snprintf(buf, sizeof(buf), + " | IPv6 %s -> %s ttl=%u proto=%u fragment id=%u (not first)", + src_str.c_str(), dst_str.c_str(), ip6->header.hop_limit, + walked.final_next_header, *walked.fragment_id); + out += buf; } else { out += summarize_transport_and_above({"IPv6", src_str, dst_str, ip6->header.hop_limit, walked.final_next_header, walked.payload}); + if (walked.fragment_id) { + out += " (fragmented, id=" + std::to_string(*walked.fragment_id) + ")"; + } } } else { out += " | IP version " + std::to_string(version) + " (unsupported)"; diff --git a/tests/test_ipv6.cpp b/tests/test_ipv6.cpp index 1cce85b..2d75e9a 100644 --- a/tests/test_ipv6.cpp +++ b/tests/test_ipv6.cpp @@ -129,6 +129,43 @@ TEST_CASE("walk_ipv6_extension_headers walks the fixed-size Fragment header") { CHECK(result.payload[0] == 0xCA); } +TEST_CASE("walk_ipv6_extension_headers reports the fragment id even for the first fragment") { + // Same shape as the test above (fragment offset 0 - the first + // fragment), but this time checking that the walk continues on to + // TCP *and* still surfaces the Identification field, which a + // caller needs to correlate this with the fragments that follow. + std::vector<unsigned char> payload = {static_cast<unsigned char>(kProtoTcp), 0x00, + 0x00, 0x00, 0x00, 0x00, 0x30, 0x39, // id = 0x3039 + 0xCA, 0xFE}; + auto result = walk_ipv6_extension_headers(kNextHeaderFragment, payload); + CHECK_FALSE(result.is_non_first_fragment); + REQUIRE(result.fragment_id.has_value()); + CHECK(*result.fragment_id == 0x3039); + CHECK(result.final_next_header == kProtoTcp); + REQUIRE(result.payload.size() == 2); // walk continued past the fragment header to real payload +} + +TEST_CASE("walk_ipv6_extension_headers stops at a non-first fragment rather than walking into " + "continuation data") { + // Fragment offset field (13 bits, packed into the top of bytes[2:3]) + // set to a nonzero value - 8 in units of 8 bytes, i.e. byte offset + // 64 into the original datagram. offset_res_m = 8 << 3 = 0x0040. + std::vector<unsigned char> payload = { + static_cast<unsigned char>(kProtoTcp), 0x00, 0x00, 0x40, 0x00, 0x00, 0x00, 0x2A, + 0xDE, 0xAD, 0xBE, 0xEF, // pure continuation data - NOT a TCP header + }; + auto result = walk_ipv6_extension_headers(kNextHeaderFragment, payload); + CHECK(result.is_non_first_fragment); + REQUIRE(result.fragment_id.has_value()); + CHECK(*result.fragment_id == 0x2A); + // final_next_header still names TCP (that's what the reassembled + // datagram eventually is), but the payload past it is untouched + // continuation data - callers must not decode it as TCP. + CHECK(result.final_next_header == kProtoTcp); + REQUIRE(result.payload.size() == 4); + CHECK(result.payload[0] == 0xDE); +} + TEST_CASE("walk_ipv6_extension_headers applies AH's 4-byte-unit length formula") { // AH: next_header(1)=TCP, payload_len(1)=1 -> total len (1+2)*4=12 bytes. std::vector<unsigned char> payload(12, 0); diff --git a/tests/test_net.cpp b/tests/test_net.cpp index 2702758..64db07f 100644 --- a/tests/test_net.cpp +++ b/tests/test_net.cpp @@ -134,6 +134,37 @@ TEST_CASE("parse_ipv4 honors IHL > 5 (options present)") { CHECK(ip->payload.empty()); } +TEST_CASE("parse_ipv4 decodes an unfragmented packet with fragment_offset zero") { + std::vector<unsigned char> bytes(20, 0); + bytes[0] = 0x45; + auto ip = parse_ipv4(bytes); + REQUIRE(ip.has_value()); + CHECK_FALSE(ip->header.more_fragments); + CHECK(ip->header.fragment_offset == 0); +} + +TEST_CASE("parse_ipv4 decodes the identification field and More Fragments flag") { + std::vector<unsigned char> bytes(20, 0); + bytes[0] = 0x45; + bytes[4] = 0x12; bytes[5] = 0x34; // identification = 0x1234 + bytes[6] = 0x20; bytes[7] = 0x00; // flags: MF=1, fragment_offset=0 (first fragment) + auto ip = parse_ipv4(bytes); + REQUIRE(ip.has_value()); + CHECK(ip->header.identification == 0x1234); + CHECK(ip->header.more_fragments); + CHECK(ip->header.fragment_offset == 0); +} + +TEST_CASE("parse_ipv4 decodes a nonzero fragment_offset in 8-byte units") { + std::vector<unsigned char> bytes(20, 0); + bytes[0] = 0x45; + bytes[6] = 0x00; bytes[7] = 0x08; // fragment_offset = 8 (i.e. byte offset 64), MF=0 (last fragment) + auto ip = parse_ipv4(bytes); + REQUIRE(ip.has_value()); + CHECK_FALSE(ip->header.more_fragments); + CHECK(ip->header.fragment_offset == 8); +} + TEST_CASE("parse_tcp decodes header fields and flags") { std::vector<unsigned char> bytes(20, 0); bytes[0] = 0x00; bytes[1] = 0x50; // src port 80 diff --git a/tests/test_summarize.cpp b/tests/test_summarize.cpp index f180f87..baf4ed4 100644 --- a/tests/test_summarize.cpp +++ b/tests/test_summarize.cpp @@ -186,6 +186,34 @@ 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 reports a non-first IPv4 fragment without decoding fake TCP/UDP") { + // Payload here is arbitrary bytes - if this were mistakenly + // handed to a transport parser it would produce a plausible- + // looking but entirely fake TCP/UDP line. The point of this test + // is that it must not. + std::vector<unsigned char> fake_continuation_data = {0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x01, 0x02, 0x03}; + + std::vector<unsigned char> ip(20, 0); + ip[0] = 0x45; + ip[4] = 0x00; ip[5] = 0x7B; // identification = 123 + ip[6] = 0x00; ip[7] = 0x08; // fragment_offset = 8 (byte offset 64), MF=0 + ip[9] = packeteer::net::kProtoTcp; + ip[12] = 10; ip[13] = 0; ip[14] = 0; ip[15] = 1; + ip[16] = 10; ip[17] = 0; ip[18] = 0; ip[19] = 2; + + std::vector<unsigned char> eth = { + 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, 0x08, 0x00, + }; + + std::vector<unsigned char> frame = eth; + frame.insert(frame.end(), ip.begin(), ip.end()); + frame.insert(frame.end(), fake_continuation_data.begin(), fake_continuation_data.end()); + + auto line = packeteer::summarize_packet(frame, DLT_EN10MB); + CHECK(line.find("fragment id=123 offset=64") != std::string::npos); + CHECK(line.find("TCP") == std::string::npos); // must not have decoded the fake continuation data +} + TEST_CASE("summarize_packet falls back to the RTCP heuristic on an unmatched UDP port") { std::vector<unsigned char> rtcp = {0x80, 0xC9, 0x00, 0x01, 0, 0, 0, 0}; // RR, len=1 -> 8 bytes |