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