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