Files
nxdns/tests/fuzz/dns_fuzz.zig
T
mokhtar 6c507992e4
CI / test (push) Successful in 1m46s
CI / test-aarch64 (push) Successful in 5m30s
CI / frontend (push) Successful in 46s
CI / cross (push) Successful in 8m12s
CI / docker (push) Successful in 3m46s
milestone 19: hygiene sweep - dead ecs surface, single-source constants, tls classification, frontend state hazards, docker smoke network fix
2026-08-07 20:39:27 +02:00

228 lines
9.0 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;
//! - a query that `stripEcs` rewrote still parses, still carries a valid OPT
//! record, no longer carries an ECS option, and kept all four of its
//! section counts.
//!
//! 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);
}
test "fuzz edns.stripEcs" {
try std.testing.fuzz({}, stripEcsTarget, 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| std.mem.doNotOptimizeAway(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);
}
}
}
/// `stripEcs` is the only attacker-facing entry point that rewrites a packet, so
/// it is the only one where a finding can be a wrong output rather than a crash.
///
/// The input is derived exactly as `parseTarget` derives it, because `stripEcs`
/// asserts its preconditions rather than returning an error: `query` must be the
/// same bytes `pkt` was parsed from, and `out` must not overlap them. `out` is a
/// separate stack buffer for that reason, and tripping either assertion from a
/// hand-built argument would report a fault no packet can cause.
fn stripEcsTarget(_: 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;
const opt_record = packet.findOptRecord(p) orelse return;
const opt = edns.parseOpt(bytes, opt_record) catch return;
// Removing an option only ever shortens the query, so a buffer the size of
// the input always holds the rewrite.
var out: [max_input]u8 = undefined;
const result = edns.stripEcs(bytes, p, opt, &out) catch return;
const rewritten = switch (result) {
.unchanged => return,
.rewritten => |message| message,
};
try std.testing.expect(rewritten.len <= bytes.len);
const stripped = try packet.parse(rewritten);
try std.testing.expectEqual(p.header.id, stripped.header.id);
try std.testing.expectEqual(p.header.qdcount, stripped.header.qdcount);
try std.testing.expectEqual(p.header.ancount, stripped.header.ancount);
try std.testing.expectEqual(p.header.nscount, stripped.header.nscount);
try std.testing.expectEqual(p.header.arcount, stripped.header.arcount);
const stripped_record = packet.findOptRecord(stripped) orelse
return error.TestOptRecordLost;
const stripped_opt = try edns.parseOpt(rewritten, stripped_record);
try std.testing.expectEqual(opt.udp_payload_size, stripped_opt.udp_payload_size);
try std.testing.expectEqual(opt.do_bit, stripped_opt.do_bit);
var options = edns.options(rewritten, stripped_opt);
while (try options.next()) |option| {
try std.testing.expect(option.code != edns.ecs_option_code);
}
try std.testing.expect(
(try edns.findOption(rewritten, stripped_opt, edns.ecs_option_code)) == null,
);
}
/// 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());
}