dns fuzz targets: smith corpus, llvm-backed fuzz artifact

This commit is contained in:
2026-08-01 01:06:11 +02:00
parent 7429b96cde
commit 5e7fbfede8
3 changed files with 387 additions and 1 deletions
+26 -1
View File
@@ -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 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 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 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"; 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", .{ tests.root_module.addAnonymousImport("test_fixtures", .{
.root_source_file = b.path("tests/fixtures/fixtures.zig"), .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"); const cross = b.step("cross", "Build static musl executables for every deploy target");
for (cross_targets) |triple| { for (cross_targets) |triple| {
+188
View File
@@ -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..]);
}
+173
View File
@@ -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=<n>` 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());
}