srdusr
aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsrdusr <[email protected]>2026-05-28 01:23:00 +0200
committersrdusr <[email protected]>2026-05-28 01:23:00 +0200
commite41dade9ac3bbd7ffbc1eec826801af1a38d8b9e (patch)
treeec53e0cc40de194e76da1f5297e37b83cc2d1419
parent0122045b492f2bd41a74769b8e6a9cefc73f988b (diff)
downloadpacketeer-e41dade9ac3bbd7ffbc1eec826801af1a38d8b9e.tar.gz
packeteer-e41dade9ac3bbd7ffbc1eec826801af1a38d8b9e.zip
Decode ICMP embedded flows - show which packet actually triggered an error
Destination Unreachable, Time Exceeded, Redirect, Source Quench, and Parameter Problem (plus their ICMPv6 equivalents) all carry, after their own fixed 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, 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. Two small is_error_type() helpers gate which ICMP types this is attempted for, since echo/timestamp/Neighbor Discovery types don't carry an embedded packet at all. Live-verified with a real traceroute to 8.8.8.8 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 the flow that triggered them, matching traceroute's own hop output.
-rw-r--r--PLAN.md21
-rw-r--r--include/packeteer/net/icmp.hpp75
-rw-r--r--include/packeteer/summarize.hpp6
-rw-r--r--tests/test_icmp.cpp86
4 files changed, 188 insertions, 0 deletions
diff --git a/PLAN.md b/PLAN.md
index 65130fe..7ad2649 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -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