Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -643,6 +643,23 @@ pub fn build(b: *std.Build) void {
hardening_test_step.dependOn(step);
}

// fuzz-tls: lives in src/ so the tls modules' relative imports resolve.
// the record layer pulls in linux_platform; no sqlite needed.
{
const step = b.step("fuzz-tls", "Fuzz TLS x509 + handshake parsers");
const mod = b.createModule(.{
.root_source_file = b.path("src/test_fuzz_tls.zig"),
.target = target,
.optimize = optimize,
.link_libc = true,
});
addLinuxImport(mod, b);
const comp = b.addTest(.{ .root_module = mod });
const run = createArtifactRunner(b, comp, "run fuzz-tls", false);
step.dependOn(&run.step);
hardening_test_step.dependOn(step);
}

// fuzz-wireguard: lives in src/ so wireguard.zig's relative imports resolve
{
const step = b.step("fuzz-wireguard", "Fuzz WireGuard handshake");
Expand Down
96 changes: 96 additions & 0 deletions src/test_fuzz_tls.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
// test_fuzz_tls — fuzz the TLS attack surface with arbitrary byte sequences.
//
// the X.509 DER verifier and the handshake message parsers consume bytes from
// a remote peer during the mTLS handshake. they must never crash (panic /
// out-of-bounds) on malformed input — only return an error or a parsed value.
//
// lives in src/ so the tls modules' relative imports resolve. driven via
// `zig build fuzz-tls` (corpus mode) or `zig build fuzz-tls -- --fuzz`.

const std = @import("std");
const x509_verify = @import("tls/x509_verify.zig");
const message_parse = @import("tls/handshake/message_parse.zig");

fn fuzzInput(smith: *std.testing.Smith, buffer: []u8) []const u8 {
if (smith.in) |input| return input;
return buffer[0..smith.slice(buffer)];
}

test "fuzz x509_verify.parseDer with arbitrary bytes" {
try std.testing.fuzz({}, struct {
fn testOne(_: void, smith: *std.testing.Smith) anyerror!void {
var buffer: [4096]u8 = undefined;
const input = fuzzInput(smith, &buffer);

// parseDer must never crash — only return error or a Parsed whose
// slices all reference within `input`.
var san_buf: [x509_verify.max_san_uris][]const u8 = undefined;
const parsed = x509_verify.parseDer(input, &san_buf) catch return;

// every returned slice must lie inside the input buffer.
assertSubslice(parsed.tbs, input);
assertSubslice(parsed.sig_der, input);
assertSubslice(parsed.subject_cn, input);
assertSubslice(parsed.issuer_cn, input);
assertSubslice(parsed.public_key_point, input);
for (parsed.san_uris) |uri| assertSubslice(uri, input);
}
}.testOne, .{
.corpus = &.{
"",
"\x30\x00",
"\x30\x82\x01\x00",
"\x30\x03\x02\x01\x00",
&[_]u8{0xff} ** 64,
},
});
}

test "fuzz handshake message stream with arbitrary bytes" {
try std.testing.fuzz({}, struct {
fn testOne(_: void, smith: *std.testing.Smith) anyerror!void {
var buffer: [4096]u8 = undefined;
const input = fuzzInput(smith, &buffer);

// walk the message stream; each step must return error or a
// bounded message — never crash.
var pos: usize = 0;
while (true) {
const msg = message_parse.nextMessage(input, &pos) catch break;
const m = msg orelse break;
assertSubslice(m.body, input);
assertSubslice(m.raw, input);

// feed each message body into the matching sub-parser; all of
// these must tolerate arbitrary bytes.
switch (m.msg_type) {
@intFromEnum(message_parse.HandshakeType.server_hello) => _ = message_parse.parseServerHello(m.body) catch {},
@intFromEnum(message_parse.HandshakeType.encrypted_extensions) => _ = message_parse.parseEncryptedExtensions(m.body) catch {},
@intFromEnum(message_parse.HandshakeType.certificate) => _ = message_parse.parseCertificateMessage(m.body) catch {},
@intFromEnum(message_parse.HandshakeType.certificate_request) => _ = message_parse.parseCertificateRequest(m.body) catch {},
@intFromEnum(message_parse.HandshakeType.certificate_verify) => _ = message_parse.parseCertificateVerify(m.body) catch {},
@intFromEnum(message_parse.HandshakeType.finished) => _ = message_parse.parseFinished(m.body) catch {},
else => {},
}
}
}
}.testOne, .{
.corpus = &.{
"",
"\x02\x00\x00\x00",
"\x0b\x00\x00\x00",
&[_]u8{0xaa} ** 32,
},
});
}

/// assert `sub` is a subslice of `parent` (or empty). a parser returning a
/// slice that points outside its input would be a real bug.
fn assertSubslice(sub: []const u8, parent: []const u8) void {
if (sub.len == 0) return;
const sub_start = @intFromPtr(sub.ptr);
const sub_end = sub_start + sub.len;
const par_start = @intFromPtr(parent.ptr);
const par_end = par_start + parent.len;
std.debug.assert(sub_start >= par_start and sub_end <= par_end);
}
2 changes: 1 addition & 1 deletion src/tls/cli/service_cert_command.zig
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ pub fn run(args: *std.process.Args.Iterator, alloc: std.mem.Allocator) Error!voi
// parse the leaf to surface the SAN URI alongside the row's stored
// timestamps. parse errors are non-fatal — we still show what we
// have from the row.
var san_buf: [8][]const u8 = undefined;
var san_buf: [x509_verify.max_san_uris][]const u8 = undefined;
var san: ?[]const u8 = null;
var subject_cn: ?[]const u8 = null;
var parsed_der: ?[]u8 = null;
Expand Down
14 changes: 13 additions & 1 deletion src/tls/client_session.zig
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,13 @@ const Sha384 = common.Sha384;
const EcdsaP256 = common.EcdsaP256;
const hash_len = common.hash_len;

/// upper bound on the server's flight of handshake messages (EE + Certificate
/// + CertVerify + Finished). real ECDSA cert chains are a few KB; 64 KB is
/// generous. a malicious server could otherwise stream handshake records
/// forever, growing the reassembly buffer without bound. exceeding this is a
/// handshake failure.
const max_handshake_bytes: usize = 64 * 1024;

pub const ClientError = error{
WriteFailed,
ReadFailed,
Expand Down Expand Up @@ -220,11 +227,16 @@ fn doHandshakeInner(
var server_finished_received = false;
var pending: std.ArrayList(u8) = .empty;
defer pending.deinit(alloc);
var total_handshake_bytes: usize = 0;

while (!server_finished_received) {
const dec = try readEncryptedRecord(alloc, fd, server_hs, &server_seq);
defer alloc.free(dec.plaintext);
if (dec.content_type != .handshake) return ClientError.HandshakeFailed;
// bound the server's handshake flight: a malicious peer could stream
// handshake records indefinitely, growing `pending` without limit.
total_handshake_bytes += dec.plaintext.len;
if (total_handshake_bytes > max_handshake_bytes) return ClientError.HandshakeFailed;
pending.appendSlice(alloc, dec.plaintext) catch return ClientError.AllocFailed;

var pos: usize = 0;
Expand Down Expand Up @@ -445,7 +457,7 @@ fn verifyServerCertVerify(
sig_der: []const u8,
transcript_hash: [hash_len]u8,
) ClientError!void {
var san_buf: [8][]const u8 = undefined;
var san_buf: [x509_verify.max_san_uris][]const u8 = undefined;
const parsed = x509_verify.parseDer(server_cert_der, &san_buf) catch return ClientError.SignatureInvalid;
if (parsed.public_key_point.len != 65) return ClientError.SignatureInvalid;

Expand Down
14 changes: 12 additions & 2 deletions src/tls/proxy/session_runtime.zig
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ const EcdsaP256 = std.crypto.sign.ecdsa.EcdsaP256Sha256;

const hash_len = Sha384.digest_length;

/// upper bound on a client's mTLS auth flight (Certificate + CertificateVerify
/// + Finished). real client cert chains are a few KB; 64 KB is generous.
/// prevents a malicious client from streaming handshake records without bound.
const max_client_auth_bytes: usize = 64 * 1024;

/// optional mTLS configuration for the server-side handshake. when set,
/// the server emits a CertificateRequest, requires (or merely inspects)
/// a client cert in response, and verifies it against `trust_ca_pem`.
Expand Down Expand Up @@ -451,10 +456,15 @@ fn acceptClientAuthAndFinished(

var saw_finished = false;
var saw_certificate = false;
var total_client_auth_bytes: usize = 0;

while (!saw_finished) {
const plaintext = try readOneEncryptedHandshakeRecordAlloc(alloc, client_fd, keys, client_seq);
defer alloc.free(plaintext);
// bound the client's auth flight (Certificate + CertVerify + Finished):
// a malicious client could otherwise stream handshake records forever.
total_client_auth_bytes += plaintext.len;
if (total_client_auth_bytes > max_client_auth_bytes) return error.InvalidClientFinished;
try pending.appendSlice(alloc, plaintext);

var pos: usize = 0;
Expand Down Expand Up @@ -512,7 +522,7 @@ fn acceptClientAuthAndFinished(

// surface the identity (use the SAN URI if present, fall back to
// subject CN) so callers can audit / authorize.
var san_buf: [8][]const u8 = undefined;
var san_buf: [x509_verify.max_san_uris][]const u8 = undefined;
const parsed = x509_verify.parseDer(client_cert_der.?, &san_buf) catch return error.InvalidClientFinished;
if (parsed.san_uris.len > 0) {
peer_identity_out.* = try alloc.dupe(u8, parsed.san_uris[0]);
Expand Down Expand Up @@ -556,7 +566,7 @@ fn readOneEncryptedHandshakeRecordAlloc(
}

fn verifyClientCertVerify(client_cert_der: []const u8, sig_der: []const u8, transcript_hash: [hash_len]u8) !void {
var san_buf: [8][]const u8 = undefined;
var san_buf: [x509_verify.max_san_uris][]const u8 = undefined;
const parsed = x509_verify.parseDer(client_cert_der, &san_buf) catch return error.UntrustedClientCert;
if (parsed.public_key_point.len != 65) return error.UntrustedClientCert;
var sec1: [65]u8 = undefined;
Expand Down
Loading
Loading