srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/include
diff options
context:
space:
mode:
Diffstat (limited to 'include')
-rw-r--r--include/packeteer/l7/dns.hpp176
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;
}
};