From 0122045b492f2bd41a74769b8e6a9cefc73f988b Mon Sep 17 00:00:00 2001 From: srdusr <99972264+srdusr@users.noreply.github.com> Date: Wed, 27 May 2026 01:04:00 +0200 Subject: Decode DNS answer records (A/AAAA/CNAME) - resolved addresses, not just ancount Probably the single most-wanted thing a packet analyzer shows that this one didn't yet: responses showed ancount=N but never what a query actually resolved to. A, AAAA, and CNAME rdata now render into readable text; every other type is still walked correctly (name/type/ttl/rdlength read and bounds-checked) but not rendered. Needed a second name reader alongside the existing question-only read_dns_name(): real answer records almost always compress their NAME field as a 2-byte pointer back to the question, which the original reader deliberately rejects. read_dns_name_following_pointers() actually follows them, bounded by a maximum jump count rather than a backward-only check - a cycle across pointers pointing at each other would still loop forever under "must point backward", but can't survive a hard cap on jumps followed. Fuzzed the new pointer-chasing logic specifically before trusting it (fuzz_dns, fuzz_summarize, ~5.1M combined runs) - exactly the kind of attacker-influenced-offset code this project's fuzzing exists for. Clean, no crashes or timeouts. Live-verified extensively on wlp1s0: a direct query to 8.8.8.8 for example.com resolved two real A records; a query for www.github.com showed a real CNAME chain; and organic background DNS traffic from this machine's own browser sessions showed AAAA records (including an 8-address response, all correctly listed) and DNS RR type 65 (HTTPS records) correctly producing no answers suffix. --- tests/test_dns.cpp | 128 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 128 insertions(+) (limited to 'tests/test_dns.cpp') diff --git a/tests/test_dns.cpp b/tests/test_dns.cpp index db5de56..45fb019 100644 --- a/tests/test_dns.cpp +++ b/tests/test_dns.cpp @@ -25,6 +25,55 @@ std::vector example_com_query() { }; } +void append_be16(std::vector& out, std::uint16_t v) { + out.push_back(static_cast(v >> 8)); + out.push_back(static_cast(v & 0xFF)); +} + +void append_be32(std::vector& out, std::uint32_t v) { + out.push_back(static_cast(v >> 24)); + out.push_back(static_cast(v >> 16)); + out.push_back(static_cast(v >> 8)); + out.push_back(static_cast(v & 0xFF)); +} + +// Builds a real, well-formed DNS response for "example.com" A, with +// `answers` real resource records appended after the question - each +// using a compressed name pointer back to the question's name (offset +// 12, right after the header), exactly how real DNS servers answer, +// rather than repeating the literal name. +struct AnswerSpec { + std::uint16_t type; + std::uint32_t ttl; + std::vector rdata; +}; + +std::vector build_dns_response(const std::vector& answers) { + std::vector bytes = { + 0x12, 0x9d, + 0x81, 0x80, // flags: QR=1 (response), RD=1, RA=1 + }; + append_be16(bytes, 1); // qdcount + append_be16(bytes, static_cast(answers.size())); + append_be16(bytes, 0); // nscount + append_be16(bytes, 0); // arcount + + bytes.insert(bytes.end(), {7, 'e', 'x', 'a', 'm', 'p', 'l', 'e', 3, 'c', 'o', 'm', 0}); + append_be16(bytes, 1); // qtype A + append_be16(bytes, 1); // qclass IN + + for (const auto& answer : answers) { + bytes.push_back(0xC0); + bytes.push_back(0x0C); // NAME: pointer to offset 12 (the question's name) + append_be16(bytes, answer.type); + append_be16(bytes, 1); // CLASS: IN + append_be32(bytes, answer.ttl); + append_be16(bytes, static_cast(answer.rdata.size())); + bytes.insert(bytes.end(), answer.rdata.begin(), answer.rdata.end()); + } + return bytes; +} + } // namespace TEST_CASE("parse_dns decodes a query") { @@ -80,3 +129,82 @@ TEST_CASE("DnsDissector::summarize returns nullopt for a truncated payload") { std::vector bytes(5, 0); CHECK_FALSE(dissector.summarize(bytes).has_value()); } + +TEST_CASE("read_dns_name_following_pointers follows a compressed name back to the question") { + auto bytes = build_dns_response({{kDnsTypeA, 300, {93, 184, 216, 34}}}); + // The answer's NAME field is the 2-byte pointer right after the + // question section (byte offset 12 + 17 = 29 in this layout). + auto result = read_dns_name_following_pointers(bytes, 29); + REQUIRE(result.has_value()); + CHECK(result->first == "example.com"); +} + +TEST_CASE("read_dns_name_following_pointers is bounded against a pointer cycle") { + // Two pointers pointing at each other - a backward-only check + // wouldn't catch this (pointer B points backward to A, which + // points forward to B), but the jump-count bound does. + std::vector bytes = {0xC0, 0x02, 0xC0, 0x00}; + CHECK_FALSE(read_dns_name_following_pointers(bytes, 0).has_value()); +} + +TEST_CASE("parse_dns decodes a single A answer") { + auto bytes = build_dns_response({{kDnsTypeA, 300, {93, 184, 216, 34}}}); + auto msg = parse_dns(bytes); + REQUIRE(msg.has_value()); + REQUIRE(msg->answers.size() == 1); + CHECK(msg->answers[0].name == "example.com"); + CHECK(msg->answers[0].type == kDnsTypeA); + CHECK(msg->answers[0].ttl == 300); + REQUIRE(msg->answers[0].rdata_text.has_value()); + CHECK(*msg->answers[0].rdata_text == "93.184.216.34"); +} + +TEST_CASE("parse_dns decodes multiple answers, e.g. a CNAME followed by an A record") { + std::vector cname_rdata = {3, 'w', 'w', 'w', 0xC0, 0x0C}; // "www" + pointer + auto bytes = build_dns_response( + {{kDnsTypeCname, 60, cname_rdata}, {kDnsTypeA, 300, {93, 184, 216, 34}}}); + auto msg = parse_dns(bytes); + REQUIRE(msg.has_value()); + REQUIRE(msg->answers.size() == 2); + REQUIRE(msg->answers[0].rdata_text.has_value()); + CHECK(*msg->answers[0].rdata_text == "www.example.com"); + REQUIRE(msg->answers[1].rdata_text.has_value()); + CHECK(*msg->answers[1].rdata_text == "93.184.216.34"); +} + +TEST_CASE("parse_dns decodes an AAAA answer") { + std::vector aaaa_rdata = {0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 1}; + auto bytes = build_dns_response({{kDnsTypeAaaa, 300, aaaa_rdata}}); + auto msg = parse_dns(bytes); + REQUIRE(msg.has_value()); + REQUIRE(msg->answers.size() == 1); + REQUIRE(msg->answers[0].rdata_text.has_value()); + CHECK(*msg->answers[0].rdata_text == "2001:0db8:0000:0000:0000:0000:0000:0001"); +} + +TEST_CASE("parse_dns leaves rdata_text unset for an undecoded record type") { + auto bytes = build_dns_response({{15 /* MX */, 60, {0, 10, 4, 'm', 'a', 'i', 'l'}}}); + auto msg = parse_dns(bytes); + REQUIRE(msg.has_value()); + REQUIRE(msg->answers.size() == 1); + CHECK(msg->answers[0].type == 15); + CHECK_FALSE(msg->answers[0].rdata_text.has_value()); +} + +TEST_CASE("parse_dns stops decoding answers on the first malformed record") { + auto bytes = build_dns_response({{kDnsTypeA, 300, {93, 184, 216, 34}}}); + bytes.resize(bytes.size() - 2); // truncate the last answer's rdata + auto msg = parse_dns(bytes); + REQUIRE(msg.has_value()); + CHECK(msg->answers.empty()); + CHECK(msg->header.ancount == 1); // the header claim is preserved even though decode failed +} + +TEST_CASE("DnsDissector::summarize includes resolved addresses for a response") { + DnsDissector dissector; + auto bytes = build_dns_response({{kDnsTypeA, 300, {93, 184, 216, 34}}}); + auto summary = dissector.summarize(bytes); + REQUIRE(summary.has_value()); + CHECK(summary->find("-> 93.184.216.34") != std::string::npos); +} -- cgit v1.2.3