230 lines
9.1 KiB
Zig
230 lines
9.1 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| {
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// `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());
|
|
}
|