diff options
| author | srdusr <[email protected]> | 2026-05-27 01:04:00 +0200 |
|---|---|---|
| committer | srdusr <[email protected]> | 2026-05-27 01:04:00 +0200 |
| commit | 0122045b492f2bd41a74769b8e6a9cefc73f988b (patch) | |
| tree | 031f1f91a19694ae520507e8cef56d38a4806f1e /tests/test_dns.cpp | |
| parent | c098f1742bb04fbe41fc6cf492cd334efef734eb (diff) | |
| download | packeteer-0122045b492f2bd41a74769b8e6a9cefc73f988b.tar.gz packeteer-0122045b492f2bd41a74769b8e6a9cefc73f988b.zip | |
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.
Diffstat (limited to 'tests/test_dns.cpp')
| -rw-r--r-- | tests/test_dns.cpp | 128 |
1 files changed, 128 insertions, 0 deletions
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<unsigned char> example_com_query() { }; } +void append_be16(std::vector<unsigned char>& out, std::uint16_t v) { + out.push_back(static_cast<unsigned char>(v >> 8)); + out.push_back(static_cast<unsigned char>(v & 0xFF)); +} + +void append_be32(std::vector<unsigned char>& out, std::uint32_t v) { + out.push_back(static_cast<unsigned char>(v >> 24)); + out.push_back(static_cast<unsigned char>(v >> 16)); + out.push_back(static_cast<unsigned char>(v >> 8)); + out.push_back(static_cast<unsigned char>(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<unsigned char> rdata; +}; + +std::vector<unsigned char> build_dns_response(const std::vector<AnswerSpec>& answers) { + std::vector<unsigned char> bytes = { + 0x12, 0x9d, + 0x81, 0x80, // flags: QR=1 (response), RD=1, RA=1 + }; + append_be16(bytes, 1); // qdcount + append_be16(bytes, static_cast<std::uint16_t>(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<std::uint16_t>(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<unsigned char> 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<unsigned char> 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<unsigned char> 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<unsigned char> 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); +} |