diff options
| -rw-r--r-- | PLAN.md | 21 | ||||
| -rw-r--r-- | include/packeteer/net/icmp.hpp | 75 | ||||
| -rw-r--r-- | include/packeteer/summarize.hpp | 6 | ||||
| -rw-r--r-- | tests/test_icmp.cpp | 86 |
4 files changed, 188 insertions, 0 deletions
@@ -664,3 +664,24 @@ None currently open. with 8 real IPv6 addresses, all correctly listed), and DNS RR type 65 (HTTPS records, ancount=0 in these captures) correctly producing no answers suffix since there was nothing to list. +- ICMP embedded-flow decoding: Destination Unreachable, Time Exceeded, + Redirect, Source Quench, and Parameter Problem (ICMPv4) / their + ICMPv6 equivalents all carry, after their own fixed header, as much + of the packet that actually triggered the error as the network could + fit - always at least its IP header plus the first 8 bytes of + payload (RFC 792/4443), exactly enough to recover TCP/UDP port + numbers. That embedded flow is the actual reason an ICMP error shows + up in a capture at all, and wasn't shown before this. Reuses + parse_ipv4()/parse_ipv6() directly on the embedded bytes rather than + a separate parser - it's a genuine (if truncated) IP packet, not a + different format, the same insight DNS's answer-record work leaned + on when it reused parse_ipv4's sibling reasoning for name + compression. Two small is_error_type() helpers gate which ICMP + types this is even attempted for (echo/timestamp/Neighbor Discovery + types don't carry an embedded packet at all), rather than relying on + parse_ipv4/parse_ipv6 to just fail gracefully on irrelevant types. + Live-verified with a real traceroute to 8.8.8.8 (traceroute -I, + ICMP-based) captured on wlp1s0: genuine Time Exceeded messages from + 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. diff --git a/include/packeteer/net/icmp.hpp b/include/packeteer/net/icmp.hpp index d2613a2..60fd1d7 100644 --- a/include/packeteer/net/icmp.hpp +++ b/include/packeteer/net/icmp.hpp @@ -1,11 +1,14 @@ #pragma once #include <cstdint> +#include <cstdio> #include <optional> #include <span> #include <string> #include "packeteer/byteio.hpp" +#include "packeteer/net/ipv4.hpp" +#include "packeteer/net/ipv6.hpp" // ICMPv4 (RFC 792) and ICMPv6 (RFC 4443) share the same first-4-byte // shape (Type, Code, Checksum) but a completely different type @@ -36,6 +39,22 @@ inline std::optional<IcmpHeader> parse_icmpv4(std::span<const unsigned char> byt return header; } +// Destination Unreachable, Source Quench, Redirect, Time Exceeded, and +// Parameter Problem are the ICMPv4 types that carry an embedded +// original packet (RFC 792) - everything else (echo, timestamp) +// doesn't, so icmpv4_embedded_flow() is only worth trying for these. +inline bool icmpv4_is_error_type(std::uint8_t type) { + return type == 3 || type == 4 || type == 5 || type == 11 || type == 12; +} + +// Destination Unreachable, Packet Too Big, Time Exceeded, and +// Parameter Problem for ICMPv6 (RFC 4443) - echo and the various +// Neighbor Discovery types (Router/Neighbor Solicitation/ +// Advertisement, Redirect) don't carry an embedded packet at all. +inline bool icmpv6_is_error_type(std::uint8_t type) { + return type == 1 || type == 2 || type == 3 || type == 4; +} + inline std::string icmpv4_type_name(std::uint8_t type) { switch (type) { case 0: return "Echo Reply"; @@ -81,4 +100,60 @@ inline std::string icmpv6_type_name(std::uint8_t type) { } } +namespace detail { +inline std::string icmp_ipv4_to_string(const Ipv4Address& addr) { + char buf[16]; + std::snprintf(buf, sizeof(buf), "%u.%u.%u.%u", addr.bytes[0], addr.bytes[1], addr.bytes[2], + addr.bytes[3]); + return buf; +} +} // namespace detail + +// Destination Unreachable, Time Exceeded, Redirect, and Parameter +// Problem all carry, after their own fixed 8-byte header, as much of +// the packet that triggered the error as the network could fit -- +// always at least its IP header plus the first 8 bytes of payload +// (RFC 792/4443), exactly enough to recover TCP/UDP port numbers. This +// is what actually answers "which flow got this error", the reason an +// ICMP error shows up in a capture in the first place. Reuses +// parse_ipv4() directly on those bytes rather than a separate parser: +// it's a genuine (if truncated) IPv4 packet, not a different format. +inline std::optional<std::string> icmpv4_embedded_flow(std::span<const unsigned char> icmp_bytes) { + if (icmp_bytes.size() < 8) return std::nullopt; + auto ip = parse_ipv4(icmp_bytes.subspan(8)); + if (!ip) return std::nullopt; + + std::string out = detail::icmp_ipv4_to_string(ip->header.src) + " -> " + + detail::icmp_ipv4_to_string(ip->header.dst) + + " proto=" + std::to_string(ip->header.protocol); + if ((ip->header.protocol == kProtoTcp || ip->header.protocol == kProtoUdp) && + ip->payload.size() >= 4) { + out += " (" + std::to_string(read_be16(ip->payload, 0)) + " -> " + + std::to_string(read_be16(ip->payload, 2)) + ")"; + } + return out; +} + +// Same idea for ICMPv6, over the embedded IPv6 packet's fixed 40-byte +// header. Extension headers on the embedded packet aren't walked (see +// walk_ipv6_extension_headers() in ipv6.hpp for the full version this +// deliberately doesn't reuse here) - an embedded packet's next_header +// naming an extension header rather than TCP/UDP directly is rare +// enough in practice not to be worth the extra complexity for a +// nested, best-effort field. +inline std::optional<std::string> icmpv6_embedded_flow(std::span<const unsigned char> icmp_bytes) { + if (icmp_bytes.size() < 8) return std::nullopt; + auto ip6 = parse_ipv6(icmp_bytes.subspan(8)); + if (!ip6) return std::nullopt; + + std::string out = ipv6_to_string(ip6->header.src) + " -> " + ipv6_to_string(ip6->header.dst) + + " next=" + std::to_string(ip6->header.next_header); + if ((ip6->header.next_header == kProtoTcp || ip6->header.next_header == kProtoUdp) && + ip6->payload.size() >= 4) { + out += " (" + std::to_string(read_be16(ip6->payload, 0)) + " -> " + + std::to_string(read_be16(ip6->payload, 2)) + ")"; + } + return out; +} + } // namespace packeteer::net diff --git a/include/packeteer/summarize.hpp b/include/packeteer/summarize.hpp index c538cec..4126eb5 100644 --- a/include/packeteer/summarize.hpp +++ b/include/packeteer/summarize.hpp @@ -218,6 +218,10 @@ inline std::string summarize_transport_and_above(const IpInfo& info) { if (icmp->identifier) { out += " id=" + std::to_string(*icmp->identifier) + " seq=" + std::to_string(*icmp->sequence); + } else if (net::icmpv4_is_error_type(icmp->type)) { + // Answers the actual reason this error shows up in a + // capture: which flow triggered it. + if (auto flow = net::icmpv4_embedded_flow(info.payload)) out += " [" + *flow + "]"; } } } else if (info.proto == net::kProtoIgmp) { @@ -234,6 +238,8 @@ inline std::string summarize_transport_and_above(const IpInfo& info) { if (icmp->identifier) { out += " id=" + std::to_string(*icmp->identifier) + " seq=" + std::to_string(*icmp->sequence); + } else if (net::icmpv6_is_error_type(icmp->type)) { + if (auto flow = net::icmpv6_embedded_flow(info.payload)) out += " [" + *flow + "]"; } } else { out += " | ICMPv6"; // truncated: at least say what it is diff --git a/tests/test_icmp.cpp b/tests/test_icmp.cpp index 520e12d..7016a72 100644 --- a/tests/test_icmp.cpp +++ b/tests/test_icmp.cpp @@ -72,6 +72,92 @@ TEST_CASE("icmpv6_type_name covers known types and falls back for unknown ones") CHECK(icmpv6_type_name(250) == "type=250"); } +namespace { + +// Builds a real ICMPv4 Destination Unreachable message wrapping a +// truncated original UDP packet - exactly the shape RFC 792 promises: +// original IP header + first 8 bytes of the original datagram's data +// (enough to reach a UDP/TCP header's two port fields). +std::vector<unsigned char> dest_unreachable_wrapping_udp() { + std::vector<unsigned char> original_ip(20, 0); + original_ip[0] = 0x45; + original_ip[9] = kProtoUdp; + original_ip[12] = 10; original_ip[13] = 0; original_ip[14] = 0; original_ip[15] = 5; + original_ip[16] = 10; original_ip[17] = 0; original_ip[18] = 0; original_ip[19] = 6; + + std::vector<unsigned char> original_udp_start = {0x1F, 0x90, 0x00, 0x35}; // src=8080, dst=53 + + std::vector<unsigned char> icmp = {3, 3, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; // port unreachable + icmp.insert(icmp.end(), original_ip.begin(), original_ip.end()); + icmp.insert(icmp.end(), original_udp_start.begin(), original_udp_start.end()); + return icmp; +} + +} // namespace + +TEST_CASE("icmpv4_embedded_flow recovers the original flow's addresses and ports") { + auto flow = icmpv4_embedded_flow(dest_unreachable_wrapping_udp()); + REQUIRE(flow.has_value()); + CHECK(*flow == "10.0.0.5 -> 10.0.0.6 proto=17 (8080 -> 53)"); +} + +TEST_CASE("icmpv4_embedded_flow omits ports for a non-TCP/UDP embedded protocol") { + std::vector<unsigned char> original_ip(20, 0); + original_ip[0] = 0x45; + original_ip[9] = kProtoIcmp; // an ICMP error about an ICMP packet (e.g. a ping that failed) + original_ip[12] = 10; original_ip[15] = 1; + original_ip[16] = 10; original_ip[19] = 2; + + std::vector<unsigned char> icmp = {11, 0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; + icmp.insert(icmp.end(), original_ip.begin(), original_ip.end()); + + auto flow = icmpv4_embedded_flow(icmp); + REQUIRE(flow.has_value()); + CHECK(*flow == "10.0.0.1 -> 10.0.0.2 proto=1"); +} + +TEST_CASE("icmpv4_embedded_flow returns nullopt when there's no room for an embedded packet") { + std::vector<unsigned char> icmp = {3, 3, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; + CHECK_FALSE(icmpv4_embedded_flow(icmp).has_value()); +} + +TEST_CASE("icmpv4_is_error_type covers the RFC 792 error types and excludes echo/timestamp") { + CHECK(icmpv4_is_error_type(3)); // Destination Unreachable + CHECK(icmpv4_is_error_type(11)); // Time Exceeded + CHECK_FALSE(icmpv4_is_error_type(8)); // Echo Request + CHECK_FALSE(icmpv4_is_error_type(13)); // Timestamp Request +} + +TEST_CASE("icmpv6_embedded_flow recovers the original flow's addresses and ports") { + std::vector<unsigned char> original_ip6(40, 0); + original_ip6[0] = 0x60; + original_ip6[6] = kProtoUdp; // next_header + original_ip6[7] = 64; // hop_limit + original_ip6[8] = 0x20; original_ip6[9] = 0x01; // src: 2001:db8::1 + original_ip6[10] = 0x0d; original_ip6[11] = 0xb8; + original_ip6[23] = 0x01; + original_ip6[24] = 0x20; original_ip6[25] = 0x01; // dst: 2001:db8::2 + original_ip6[26] = 0x0d; original_ip6[27] = 0xb8; + original_ip6[39] = 0x02; + + std::vector<unsigned char> original_udp_start = {0x1F, 0x90, 0x00, 0x35}; + + std::vector<unsigned char> icmp = {1, 4, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; // port unreachable + icmp.insert(icmp.end(), original_ip6.begin(), original_ip6.end()); + icmp.insert(icmp.end(), original_udp_start.begin(), original_udp_start.end()); + + auto flow = icmpv6_embedded_flow(icmp); + REQUIRE(flow.has_value()); + CHECK(*flow == "2001:db8::1 -> 2001:db8::2 next=17 (8080 -> 53)"); +} + +TEST_CASE("icmpv6_is_error_type covers the RFC 4443 error types and excludes echo/Redirect") { + CHECK(icmpv6_is_error_type(1)); // Destination Unreachable + CHECK(icmpv6_is_error_type(3)); // Time Exceeded + CHECK_FALSE(icmpv6_is_error_type(128)); // Echo Request + CHECK_FALSE(icmpv6_is_error_type(137)); // Redirect: different, not generic embedded-packet format +} + TEST_CASE("the same type number means something different in each protocol's table") { // The whole reason these are two separate tables, not one shared by // number: ICMPv4's echo request is type 8, but ICMPv6's type 8 |