diff options
Diffstat (limited to 'include')
| -rw-r--r-- | include/packeteer/l7/dns.hpp | 176 |
1 files changed, 162 insertions, 14 deletions
diff --git a/include/packeteer/l7/dns.hpp b/include/packeteer/l7/dns.hpp index 2626887..b122099 100644 --- a/include/packeteer/l7/dns.hpp +++ b/include/packeteer/l7/dns.hpp @@ -1,23 +1,30 @@ #pragma once #include <cstdint> +#include <cstdio> #include <optional> #include <span> #include <string> #include <utility> +#include <vector> #include "packeteer/byteio.hpp" #include "packeteer/l7/dissector.hpp" -// Hand-rolled DNS message parsing: header + the first question record. -// Answer/authority/additional records aren't decoded (not needed for a -// one-line summary), so name-compression pointers there are never -// followed - a pointer in the question section itself is rejected -// rather than chased, keeping this a pure forward scan with no risk of -// a pointer loop. +// Hand-rolled DNS message parsing: header, the first question record, +// and (for responses) answer records - enough to show what a query +// actually resolved to, not just that it resolved. Only A, AAAA, and +// CNAME rdata are decoded into readable text; other types are still +// walked over correctly (name, type, ttl, rdlength all read and +// bounds-checked) but their rdata isn't rendered, matching this +// project's pattern of decoding the common cases precisely rather +// than guessing at everything. namespace packeteer::net { inline constexpr std::uint16_t kDnsPort = 53; +inline constexpr std::uint16_t kDnsTypeA = 1; +inline constexpr std::uint16_t kDnsTypeCname = 5; +inline constexpr std::uint16_t kDnsTypeAaaa = 28; struct DnsHeader { std::uint16_t id; @@ -33,15 +40,33 @@ struct DnsQuestion { std::uint16_t qtype; }; +struct DnsAnswer { + std::string name; + std::uint16_t type; + std::uint32_t ttl; + // Rendered rdata for A/AAAA/CNAME only; nullopt for every other + // type (still correctly skipped over, just not rendered). + std::optional<std::string> rdata_text; +}; + struct DnsMessage { DnsHeader header; std::optional<DnsQuestion> question; // first question only + std::vector<DnsAnswer> answers; // up to kMaxAnswersDecoded }; -// Reads a (possibly multi-label) dotted name starting at offset. -// Returns the name and the offset just past it, or nullopt on -// truncation or a compression pointer (0xC0 prefix - valid in -// answer/authority records, not supported here). +// Bounds how many answer records get decoded into DnsMessage::answers +// - real responses rarely carry more than a handful; a message +// claiming far more either is unusual or is trying to make this do +// needless work for a one-line summary. +inline constexpr std::size_t kMaxAnswersDecoded = 16; + +// Reads a (possibly multi-label) dotted name starting at offset, for +// the question section specifically. Returns the name and the offset +// just past it, or nullopt on truncation or a compression pointer +// (0xC0 prefix) - real messages don't compress the question's own +// name (there's nothing earlier in the message to point back to), so +// rejecting one here is a correctness check, not a missing feature. inline std::optional<std::pair<std::string, std::size_t>> read_dns_name( std::span<const unsigned char> bytes, std::size_t offset) { std::string name; @@ -52,7 +77,7 @@ inline std::optional<std::pair<std::string, std::size_t>> read_dns_name( ++offset; break; } - if ((len & 0xC0) == 0xC0) return std::nullopt; // compression pointer: unsupported + if ((len & 0xC0) == 0xC0) return std::nullopt; // compression pointer: unsupported here ++offset; if (offset + len > bytes.size()) return std::nullopt; if (!name.empty()) name += '.'; @@ -62,6 +87,103 @@ inline std::optional<std::pair<std::string, std::size_t>> read_dns_name( return std::make_pair(std::move(name), offset); } +// Reads a name starting at offset, following compression pointers this +// time - needed for answer records, which almost always compress +// their NAME field as a 2-byte pointer back to the question (RFC 1035 +// 4.1.4). Bounded by a maximum jump count rather than just "pointers +// must point backward": a cycle across several pointers pointing at +// each other would still loop forever under a backward-only check, but +// can't survive a hard cap on how many jumps are followed. Returns the +// name and the offset just past *this record's own bytes* (i.e. past +// the pointer itself if one was used, not past wherever it pointed) -- +// that's what the caller needs to continue parsing the rest of the +// record. +inline std::optional<std::pair<std::string, std::size_t>> read_dns_name_following_pointers( + std::span<const unsigned char> bytes, std::size_t offset) { + std::string name; + std::size_t record_end = static_cast<std::size_t>(-1); // set once, on the first pointer/end + std::size_t pos = offset; + constexpr int kMaxJumps = 20; + int jumps = 0; + + while (true) { + if (pos >= bytes.size()) return std::nullopt; + std::uint8_t len = bytes[pos]; + + if (len == 0) { + ++pos; + if (record_end == static_cast<std::size_t>(-1)) record_end = pos; + break; + } + if ((len & 0xC0) == 0xC0) { + if (pos + 1 >= bytes.size()) return std::nullopt; + std::uint16_t pointer = + static_cast<std::uint16_t>(((len & 0x3F) << 8) | bytes[pos + 1]); + if (record_end == static_cast<std::size_t>(-1)) record_end = pos + 2; + if (++jumps > kMaxJumps) return std::nullopt; + if (pointer >= bytes.size()) return std::nullopt; + pos = pointer; + continue; + } + + ++pos; + if (pos + len > bytes.size()) return std::nullopt; + if (!name.empty()) name += '.'; + for (std::uint8_t i = 0; i < len; ++i) name += static_cast<char>(bytes[pos + i]); + pos += len; + } + + return std::make_pair(std::move(name), record_end); +} + +// A plain, uncompressed hex-group IPv6 formatter local to this file -- +// deliberately not the RFC 5952 canonical/zero-compressed form +// summarize.hpp's ipv6_to_string produces, since reaching that would +// mean dns.hpp depending on summarize.hpp, which already depends on +// dns.hpp (the same circular-include reasoning behind arp_summary +// living in summarize.hpp and dhcp.hpp's yiaddr formatter being local +// to it). Correct, just not maximally compact. +inline std::string dns_aaaa_to_string(std::span<const unsigned char> bytes16) { + char buf[40]; + std::snprintf(buf, sizeof(buf), "%02x%02x:%02x%02x:%02x%02x:%02x%02x:%02x%02x:%02x%02x:%02x%02x:%02x%02x", + bytes16[0], bytes16[1], bytes16[2], bytes16[3], bytes16[4], bytes16[5], + bytes16[6], bytes16[7], bytes16[8], bytes16[9], bytes16[10], bytes16[11], + bytes16[12], bytes16[13], bytes16[14], bytes16[15]); + return buf; +} + +inline std::optional<std::pair<DnsAnswer, std::size_t>> read_dns_answer( + std::span<const unsigned char> bytes, std::size_t offset) { + auto name_result = read_dns_name_following_pointers(bytes, offset); + if (!name_result) return std::nullopt; + auto& [name, next] = *name_result; + + // TYPE(2) CLASS(2) TTL(4) RDLENGTH(2) + if (next + 10 > bytes.size()) return std::nullopt; + std::uint16_t type = read_be16(bytes, next); + std::uint32_t ttl = read_be32(bytes, next + 4); + std::uint16_t rdlength = read_be16(bytes, next + 8); + std::size_t rdata_start = next + 10; + if (rdata_start + rdlength > bytes.size()) return std::nullopt; + + DnsAnswer answer{name, type, ttl, std::nullopt}; + + if (type == kDnsTypeA && rdlength == 4) { + char buf[16]; + std::snprintf(buf, sizeof(buf), "%u.%u.%u.%u", bytes[rdata_start], bytes[rdata_start + 1], + bytes[rdata_start + 2], bytes[rdata_start + 3]); + answer.rdata_text = buf; + } else if (type == kDnsTypeAaaa && rdlength == 16) { + answer.rdata_text = dns_aaaa_to_string(bytes.subspan(rdata_start, 16)); + } else if (type == kDnsTypeCname) { + if (auto cname = read_dns_name_following_pointers(bytes, rdata_start)) { + answer.rdata_text = cname->first; + } + } + + return std::make_pair(std::move(answer), rdata_start + rdlength); +} + inline std::optional<DnsMessage> parse_dns(std::span<const unsigned char> bytes) { if (bytes.size() < 12) return std::nullopt; @@ -74,15 +196,32 @@ inline std::optional<DnsMessage> parse_dns(std::span<const unsigned char> bytes) header.qdcount = read_be16(bytes, 4); header.ancount = read_be16(bytes, 6); - DnsMessage msg{header, std::nullopt}; + DnsMessage msg{header, std::nullopt, {}}; + + std::size_t offset = 12; if (header.qdcount >= 1) { - if (auto result = read_dns_name(bytes, 12)) { + if (auto result = read_dns_name(bytes, offset)) { auto& [name, next_offset] = *result; if (next_offset + 4 <= bytes.size()) { - msg.question = DnsQuestion{std::move(name), read_be16(bytes, next_offset)}; + msg.question = DnsQuestion{name, read_be16(bytes, next_offset)}; + offset = next_offset + 4; // past QTYPE(2) + QCLASS(2) } } } + + // Answer records only get walked once the question parsed cleanly + // - without a reliable offset into the message, there's no safe + // place to start reading them from. + if (msg.question) { + std::size_t count = std::min<std::size_t>(header.ancount, kMaxAnswersDecoded); + for (std::size_t i = 0; i < count; ++i) { + auto result = read_dns_answer(bytes, offset); + if (!result) break; // malformed: stop, keep what was decoded so far + msg.answers.push_back(std::move(result->first)); + offset = result->second; + } + } + return msg; } @@ -101,6 +240,15 @@ public: if (msg->question) { out += " " + msg->question->name + " type=" + std::to_string(msg->question->qtype); } + if (!msg->answers.empty()) { + std::string rendered; + for (const auto& answer : msg->answers) { + if (!answer.rdata_text) continue; + if (!rendered.empty()) rendered += ","; + rendered += *answer.rdata_text; + } + if (!rendered.empty()) out += " -> " + rendered; + } return out; } }; |