srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/tests/test_dns.cpp
diff options
context:
space:
mode:
authorsrdusr <[email protected]>2026-05-27 01:04:00 +0200
committersrdusr <[email protected]>2026-05-27 01:04:00 +0200
commit0122045b492f2bd41a74769b8e6a9cefc73f988b (patch)
tree031f1f91a19694ae520507e8cef56d38a4806f1e /tests/test_dns.cpp
parentc098f1742bb04fbe41fc6cf492cd334efef734eb (diff)
downloadpacketeer-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.cpp128
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);
+}