From 5e7fbfede88279efe4fde7c6058d2844fa29400f Mon Sep 17 00:00:00 2001 From: m5r Date: Sat, 1 Aug 2026 01:06:11 +0200 Subject: [PATCH] dns fuzz targets: smith corpus, llvm-backed fuzz artifact --- build.zig | 27 +++++- tests/fuzz/corpus.zig | 188 ++++++++++++++++++++++++++++++++++++++++ tests/fuzz/dns_fuzz.zig | 173 ++++++++++++++++++++++++++++++++++++ 3 files changed, 387 insertions(+), 1 deletion(-) create mode 100644 tests/fuzz/corpus.zig create mode 100644 tests/fuzz/dns_fuzz.zig diff --git a/build.zig b/build.zig index eb50f2a..3a2239f 100644 --- a/build.zig +++ b/build.zig @@ -18,6 +18,7 @@ pub fn build(b: *std.Build) void { const integration = b.option(bool, "integration", "Run hermetic integration tests (loopback sockets only)") orelse false; const live = b.option(bool, "live", "Run tests that reach external network hosts") orelse false; + const fuzz = b.option(bool, "fuzz", "Build the fuzz targets with the LLVM backend (required for --fuzz)") orelse false; const version_string = b.option([]const u8, "version-string", "Version reported by `nxdns version`") orelse "0.1.0-dev"; const git_commit = b.option([]const u8, "git-commit", "Git commit reported by `nxdns version`") orelse "unknown"; @@ -52,7 +53,31 @@ pub fn build(b: *std.Build) void { tests.root_module.addAnonymousImport("test_fixtures", .{ .root_source_file = b.path("tests/fixtures/fixtures.zig"), }); - b.step("test", "Run the test suite").dependOn(&b.addRunArtifact(tests).step); + const test_step = b.step("test", "Run the test suite"); + test_step.dependOn(&b.addRunArtifact(tests).step); + + // Fuzz targets compile as a second test artifact with `dns` as a named module + // (a file belongs to one module per compilation; the aggregator keeps owning + // the in-file tests). `-Dfuzz` opts into the LLVM backend, which `--fuzz` + // needs for sanitizer coverage; stock 0.16.0 also requires a patched + // test_runner.zig for fuzz mode — see specs/milestone-2.md. + const dns_mod = b.createModule(.{ + .root_source_file = b.path("src/dns/dns.zig"), + .target = target, + .optimize = optimize, + }); + const fuzz_mod = b.createModule(.{ + .root_source_file = b.path("tests/fuzz/dns_fuzz.zig"), + .target = target, + .optimize = optimize, + }); + fuzz_mod.addImport("dns", dns_mod); + const fuzz_tests = b.addTest(.{ + .name = "fuzz", + .use_llvm = if (fuzz) true else null, + .root_module = fuzz_mod, + }); + test_step.dependOn(&b.addRunArtifact(fuzz_tests).step); const cross = b.step("cross", "Build static musl executables for every deploy target"); for (cross_targets) |triple| { diff --git a/tests/fuzz/corpus.zig b/tests/fuzz/corpus.zig new file mode 100644 index 0000000..86fc527 --- /dev/null +++ b/tests/fuzz/corpus.zig @@ -0,0 +1,188 @@ +//! Seed corpus for `dns_fuzz.zig`. +//! +//! The packets below are copies of the fixtures `src/dns/packet.zig` keeps +//! private to its own tests, plus the malformed shapes worth reaching on the +//! first run rather than after the fuzzer rediscovers them. They are copies on +//! purpose: a fuzz seed that changes whenever a unit-test fixture is edited is +//! a corpus that silently loses coverage. +//! +//! `std.testing.Smith` does not consume a corpus entry as raw parser input. It +//! reads a byte stream: a slice is a little-endian `u32` length followed by +//! that many bytes, and an integer is a little-endian `u64`. `sliceInput` and +//! `sliceIntInput` encode packets into that stream, which is why every entry in +//! `inputs` is length-prefixed. + +const std = @import("std"); +const dns = @import("dns"); + +/// A query for example.com A with an EDNS(0) OPT record advertising 4096 +/// bytes: id 0x1234, RD set, one question, one additional. +pub const query = + "\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x01" ++ + "\x07example\x03com\x00\x00\x01\x00\x01" ++ + "\x00\x00\x29\x10\x00\x00\x00\x00\x00\x00\x00"; + +/// The matching response: a CNAME to www.example.com and its A record, both +/// with compressed owner names, plus the echoed OPT record. +pub const response = + "\x12\x34\x81\x80\x00\x01\x00\x02\x00\x00\x00\x01" ++ + "\x07example\x03com\x00\x00\x01\x00\x01" ++ + "\xc0\x0c\x00\x05\x00\x01\x00\x00\x01\x2c\x00\x06\x03www\xc0\x0c" ++ + "\xc0\x29\x00\x01\x00\x01\x00\x00\x00\x3c\x00\x04\x5d\xb8\xd8\x22" ++ + "\x00\x00\x29\x10\x00\x00\x00\x00\x00\x00\x00"; + +/// One record in every section, all with root owner names and A rdata, so the +/// walk crosses all three record sections. +pub const all_sections = "\x00\x01\x81\x80\x00\x01\x00\x01\x00\x01\x00\x01" ++ + "\x00\x00\x01\x00\x01" ++ + "\x00\x00\x01\x00\x01\x00\x00\x00\x64\x00\x04\x01\x01\x01\x01" ++ + "\x00\x00\x01\x00\x01\x00\x00\x00\x1e\x00\x04\x02\x02\x02\x02" ++ + "\x00\x00\x01\x00\x01\x00\x00\x00\x50\x00\x04\x03\x03\x03\x03"; + +/// An OPT record whose TTL word carries the DO bit, which `decrementTtls` must +/// leave alone. +pub const opt_do_bit = "\x12\x34\x81\x80\x00\x00\x00\x01\x00\x00\x00\x01" ++ + "\x00\x00\x01\x00\x01\x00\x00\x00\x64\x00\x04\x01\x02\x03\x04" ++ + "\x00\x00\x29\x10\x00\x00\x00\x80\x00\x00\x00"; + +/// A query whose OPT record carries an EDNS Client Subnet option for +/// 192.168.1.0/24, so the option list and the ECS parser both get exercised. +pub const opt_ecs = "\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x01" ++ + "\x07example\x03com\x00\x00\x01\x00\x01" ++ + "\x00\x00\x29\x10\x00\x00\x00\x00\x00\x00\x0b" ++ + "\x00\x08\x00\x07\x00\x01\x18\x00\xc0\xa8\x01"; + +/// A question name that points at itself: no chain can terminate. +pub const pointer_loop = "\x00\x01\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00" ++ + "\xc0\x0c\x00\x01\x00\x01"; + +/// A question name that points forwards, which the strictly-decreasing rule +/// rejects. +pub const forward_pointer = "\x00\x01\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00" ++ + "\xc0\x12\x00\x01\x00\x01\x03com\x00"; + +/// A leading 0x40 control byte: a reserved label type, and also what a 64-byte +/// label encodes to. +pub const reserved_label = "\x00\x01\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00" ++ + "\x40abc\x00\x00\x01\x00\x01"; + +/// An RDLENGTH of 0x40 with four bytes of rdata behind it. +pub const rdlength_overrun = "\x00\x01\x81\x80\x00\x00\x00\x01\x00\x00\x00\x00" ++ + "\x00\x00\x01\x00\x01\x00\x00\x00\x0a\x00\x40\x01\x02\x03\x04"; + +/// ANCOUNT claims two answers and one follows. +pub const count_too_large = "\x00\x01\x81\x80\x00\x00\x00\x02\x00\x00\x00\x00" ++ + "\x00\x00\x01\x00\x01\x00\x00\x00\x0a\x00\x04\x01\x02\x03\x04"; + +/// A header and nothing else, which is well-formed. +pub const header_only = "\x00\x01\x81\x83" ++ "\x00" ** 8; + +pub const packets = [_][]const u8{ + query, + response, + all_sections, + opt_do_bit, + opt_ecs, + pointer_loop, + forward_pointer, + reserved_label, + rdlength_overrun, + count_too_large, + header_only, +}; + +/// A name at offset 0 followed by `link_count` compression pointers, each +/// pointing at the one before it. Parsing from the topmost link follows +/// `link_count` jumps, so a chain longer than `max_compression_jumps` is the +/// shortest input that reaches the jump cap on a chain that is otherwise legal. +fn pointerChain(comptime link_count: usize) *const [3 + 2 * link_count]u8 { + return &struct { + const value: [3 + 2 * link_count]u8 = blk: { + var buf: [3 + 2 * link_count]u8 = undefined; + buf[0..3].* = "\x01a\x00".*; + for (0..link_count) |i| { + const target: u16 = if (i == 0) 0 else @intCast(3 + 2 * (i - 1)); + std.mem.writeInt(u16, buf[3 + 2 * i ..][0..2], 0xc000 | target, .big); + } + break :blk buf; + }; + }.value; +} + +fn chainTopOffset(comptime link_count: usize) u64 { + return 3 + 2 * (link_count - 1); +} + +const max_jumps = dns.types.max_compression_jumps; + +/// A chain that ends exactly at the jump cap, and one link past it. +pub const chain_at_cap = pointerChain(max_jumps); +pub const chain_past_cap = pointerChain(max_jumps + 1); + +/// Encodes `bytes` as a single `Smith.slice` value. +fn sliceInput(comptime bytes: []const u8) *const [4 + bytes.len]u8 { + return &struct { + const value: [4 + bytes.len]u8 = blk: { + var buf: [4 + bytes.len]u8 = undefined; + std.mem.writeInt(u32, buf[0..4], @intCast(bytes.len), .little); + buf[4..].* = bytes[0..bytes.len].*; + break :blk buf; + }; + }.value; +} + +/// Encodes `bytes` as a `Smith.slice` value followed by one integer, which the +/// name target reads as an offset and the TTL target as an elapsed time. +fn sliceIntInput(comptime bytes: []const u8, comptime int: u64) *const [12 + bytes.len]u8 { + return &struct { + const value: [12 + bytes.len]u8 = blk: { + var buf: [12 + bytes.len]u8 = undefined; + buf[0 .. 4 + bytes.len].* = sliceInput(bytes).*; + std.mem.writeInt(u64, buf[4 + bytes.len ..][0..8], int, .little); + break :blk buf; + }; + }.value; +} + +/// Every seed, in the byte stream `Smith` reads. All three targets take the +/// same corpus: each starts with one slice, and the two that read a trailing +/// integer fall back to the low end of its range when an entry has none. +pub const inputs = [_][]const u8{ + sliceInput(query), + sliceInput(response), + sliceInput(all_sections), + sliceInput(opt_do_bit), + sliceInput(opt_ecs), + sliceInput(pointer_loop), + sliceInput(forward_pointer), + sliceInput(reserved_label), + sliceInput(rdlength_overrun), + sliceInput(count_too_large), + sliceInput(header_only), + // The compressed response, read from the offset of each compressed owner + // name rather than from zero. + sliceIntInput(response, 29), + sliceIntInput(response, 47), + sliceIntInput(chain_at_cap, chainTopOffset(max_jumps)), + sliceIntInput(chain_past_cap, chainTopOffset(max_jumps + 1)), + // A response aged past every TTL it carries. + sliceIntInput(response, 1_000_000), +}; + +test "the pointer chain has the documented shape" { + try std.testing.expectEqual(@as(usize, 3 + 2 * max_jumps), chain_at_cap.len); + try std.testing.expectEqual(@as(u64, 3 + 2 * (max_jumps - 1)), chainTopOffset(max_jumps)); + // The topmost link points at the one below it. + try std.testing.expectEqual( + @as(u16, 0xc000 | (3 + 2 * (max_jumps - 2))), + std.mem.readInt(u16, chain_at_cap[chainTopOffset(max_jumps)..][0..2], .big), + ); + // The lowest link points at the name itself. + try std.testing.expectEqual(@as(u16, 0xc000), std.mem.readInt(u16, chain_at_cap[3..5], .big)); +} + +test "a slice input carries its own length" { + const encoded = sliceInput(query); + try std.testing.expectEqual(@as(u32, query.len), std.mem.readInt(u32, encoded[0..4], .little)); + try std.testing.expectEqualSlices(u8, query, encoded[4..]); +} diff --git a/tests/fuzz/dns_fuzz.zig b/tests/fuzz/dns_fuzz.zig new file mode 100644 index 0000000..af66784 --- /dev/null +++ b/tests/fuzz/dns_fuzz.zig @@ -0,0 +1,173 @@ +//! Fuzz targets for the DNS wire-format core (`src/dns/`). +//! +//! Every target holds the same contract: rejecting attacker bytes with an +//! error is correct, panicking, overflowing or reading out of bounds is not. +//! Where a parse succeeds the target then checks the invariant the rest of the +//! server is entitled to rely on: +//! +//! - a `Packet` that `parse` accepted iterates cleanly in every section, and +//! its OPT record, option list and typed RDATA accessors cannot panic; +//! - a `Name` that `parse` accepted ends inside the packet and survives a +//! round trip through presentation form; +//! - a buffer that `decrementTtls` aged still parses, and no record it aged +//! holds a TTL below the minimum it reported. +//! +//! The targets stay inside the documented safe entry points. `setId` is called +//! only on a buffer long enough to hold a header, because it asserts that +//! length rather than returning an error. `ResponseBuilder` is absent: its +//! assertions guard programmer error, not attacker input, so tripping them +//! would report a fuzz finding that no packet can cause. +//! +//! Runner semantics: under a plain `zig build test` each target runs once per +//! corpus entry plus once on empty input, which makes the corpus a regression +//! suite. `zig build test --fuzz=` gives each target `n` generated inputs; +//! the limit counts iterations, not seconds. + +const std = @import("std"); +const dns = @import("dns"); +const corpus = @import("corpus.zig"); + +const packet = dns.packet; +const name = dns.name; +const record = dns.record; +const edns = dns.edns; +const types = dns.types; +const Smith = std.testing.Smith; + +/// Inputs are capped well under the 65535-byte protocol maximum. The parsers +/// are length-driven — every bound comes from a count, an RDLENGTH or the +/// slice end — so a longer buffer adds iterations, not code paths, and a +/// smaller one buys more executions per second. +const max_input = 4096; + +const fuzz_options: std.testing.FuzzInputOptions = .{ .corpus = &corpus.inputs }; + +test "fuzz packet.parse" { + try std.testing.fuzz({}, parseTarget, fuzz_options); +} + +test "fuzz name.parse" { + try std.testing.fuzz({}, nameTarget, fuzz_options); +} + +test "fuzz packet.decrementTtls" { + try std.testing.fuzz({}, ttlTarget, fuzz_options); +} + +fn parseTarget(_: void, smith: *Smith) anyerror!void { + var buf: [max_input]u8 = undefined; + const bytes = buf[0..smith.slice(&buf)]; + + const p = packet.parse(bytes) catch return; + + var questions = packet.questions(p); + while (try questions.next()) |_| {} + + for ([_]packet.RecordIterator{ + packet.answers(p), + packet.authorities(p), + packet.additionals(p), + }) |section| { + var records = section; + while (try records.next()) |rec| sweepRdata(bytes, rec); + } + + _ = packet.firstQuestion(p); + + const opt_record = packet.findOptRecord(p) orelse return; + // A non-OPT record cannot reach here, so `NotOpt` is impossible; a + // malformed option list is not, and is a legitimate rejection. + const opt = edns.parseOpt(bytes, opt_record) catch return; + + // `parseOpt` validates the whole option list, so an `OptRecord` it + // returned must iterate to the end without error. + var options = edns.options(bytes, opt); + while (try options.next()) |option| { + if (option.code == edns.ecs_option_code) ignore(edns.parseEcs(option.data)); + } + ignore(edns.findOption(bytes, opt, edns.ecs_option_code)); +} + +fn nameTarget(_: void, smith: *Smith) anyerror!void { + var buf: [max_input]u8 = undefined; + const bytes = buf[0..smith.slice(&buf)]; + const offset = smith.valueRangeAtMost(u32, 0, @intCast(bytes.len)); + + const parsed = name.parse(bytes, offset) catch return; + + try std.testing.expect(parsed.end <= bytes.len); + try std.testing.expect(parsed.name.len >= 1); + try std.testing.expect(parsed.name.len <= types.max_name_len); + try std.testing.expectEqual(@as(u8, 0), parsed.name.bytes[parsed.name.len - 1]); + + try expectTextRoundTrip(parsed.name); +} + +fn ttlTarget(_: void, smith: *Smith) anyerror!void { + var buf: [max_input]u8 = undefined; + const bytes = buf[0..smith.slice(&buf)]; + const elapsed = smith.value(u32); + + if (bytes.len >= types.header_len) { + packet.setId(bytes, @truncate(elapsed)); + try std.testing.expectEqual( + @as(u16, @truncate(elapsed)), + std.mem.readInt(u16, bytes[0..2], .big), + ); + } + + const minimum = packet.decrementTtls(bytes, elapsed) catch return; + + // Aging rewrites TTL words in place and nothing else, so a buffer that + // aged cleanly is still structurally valid. + const p = try packet.parse(bytes); + const reported = minimum orelse return; + + for ([_]packet.RecordIterator{ + packet.answers(p), + packet.authorities(p), + packet.additionals(p), + }) |section| { + var records = section; + while (try records.next()) |rec| { + if (rec.rtype == .opt) continue; + try std.testing.expect(rec.ttl >= reported); + } + } +} + +/// Runs every typed RDATA accessor over a record. Each one rejects a record of +/// the wrong type or a truncated RDATA, so only a panic is a finding here. +fn sweepRdata(bytes: []const u8, rec: record.Record) void { + ignore(record.rdataA(bytes, rec)); + ignore(record.rdataAaaa(bytes, rec)); + ignore(record.rdataCname(bytes, rec)); + ignore(record.rdataSoaMinimumTtl(bytes, rec)); + std.mem.doNotOptimizeAway(rec.rdata.slice(bytes)); +} + +fn ignore(result: anytype) void { + if (result) |value| std.mem.doNotOptimizeAway(value) else |_| {} +} + +/// Presentation form is lossy for labels holding a dot: `formatText` joins +/// labels with dots and `fromText` splits on them, so "a.b" as one label and +/// as two labels write the same text. Wire parsing accepts either, so the +/// round trip is only a property of names that carry no dot inside a label. +fn expectTextRoundTrip(n: name.Name) !void { + var i: usize = 0; + while (i < n.len and n.bytes[i] != 0) { + const label_len = n.bytes[i]; + if (std.mem.findScalar(u8, n.bytes[i + 1 ..][0..label_len], '.') != null) return; + i += 1 + @as(usize, label_len); + } + + // Presentation form replaces each length byte with a separating dot and + // drops the terminating zero, so it never exceeds the wire length. + var text_buf: [types.max_name_len]u8 = undefined; + var w: std.Io.Writer = .fixed(&text_buf); + try name.formatText(n, &w); + + const round_tripped = try name.fromText(w.buffered()); + try std.testing.expectEqualSlices(u8, n.wire(), round_tripped.wire()); +}