Files
nxdns/tests/fuzz/dns_fuzz.zig
T

174 lines
6.5 KiB
Zig

//! 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());
}