From 43f626151772f65986a6e36daf61b37bc815a6a7 Mon Sep 17 00:00:00 2001 From: Kacy Fortner Date: Tue, 23 Jun 2026 01:24:02 +0000 Subject: [PATCH 1/3] fix: bounds-check every length-driven slice in the x509 der parser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit the der walker sliced body[pos .. pos + hdr.length] from attacker- controlled length fields without checking the bound first in ~15 spots. in releasesafe an out-of-bounds slice panics — so a malformed peer cert could crash the mtls handshake thread (remote dos). add a sliceChecked(buf, start, len) helper that validates the bound (overflow-safe subtraction form) and returns error.InvalidCert, and route every length-driven slice in parseDer / parseTbsFields / extractCnFromName / extractEcPointFromSpki / extractSanUrisFromExtensions / fillSanUris / extractFirstOid through it. also fix SAN handling to fail closed: a cert with more SAN URIs than the cap previously dropped the rest silently, so a required identity URI past the cap would never match. raise the cap (8 -> 32) and reject on overflow instead of truncating. new tests: parseDer rejects every truncation prefix and single-byte header corruptions without panicking; fillSanUris rejects an over-cap SAN list. existing positive round-trips unchanged. --- src/tls/x509_verify.zig | 147 +++++++++++++++++++++++++++++++--------- 1 file changed, 116 insertions(+), 31 deletions(-) diff --git a/src/tls/x509_verify.zig b/src/tls/x509_verify.zig index 0c892a6..4e469f6 100644 --- a/src/tls/x509_verify.zig +++ b/src/tls/x509_verify.zig @@ -63,7 +63,18 @@ pub const Verified = struct { peer_san_uris: []const []const u8, }; -const max_san_uris = 8; +pub const max_san_uris = 32; + +/// bounds-checked slice of `buf[start .. start + len]`. every length in a DER +/// cert is attacker-controlled, so a raw `buf[pos .. pos + hdr.length]` would +/// panic (ReleaseSafe) on a malformed cert — i.e. a remote DoS on the handshake +/// thread. routing every length-driven slice through this turns that into a +/// clean `error.InvalidCert`. the subtraction form avoids `start + len` +/// overflow. +fn sliceChecked(buf: []const u8, start: usize, len: usize) Error![]const u8 { + if (start > buf.len or len > buf.len - start) return Error.InvalidCert; + return buf[start .. start + len]; +} pub fn parseDer(der: []const u8, san_buf: *[max_san_uris][]const u8) Error!Parsed { var pos: usize = 0; @@ -74,26 +85,26 @@ pub fn parseDer(der: []const u8, san_buf: *[max_san_uris][]const u8) Error!Parse const tbs_hdr = x509_parse.parseAsn1Tag(der, &pos) catch return Error.InvalidCert; if (tbs_hdr.tag != 0x30) return Error.InvalidCert; const tbs_body_start = pos; - if (tbs_body_start + tbs_hdr.length > der.len) return Error.InvalidCert; + const tbs_body = try sliceChecked(der, tbs_body_start, tbs_hdr.length); pos += tbs_hdr.length; const tbs = der[tbs_start..pos]; // signatureAlgorithm AlgorithmIdentifier ::= SEQUENCE { OID, params? } const sig_alg = x509_parse.parseAsn1Tag(der, &pos) catch return Error.InvalidCert; if (sig_alg.tag != 0x30) return Error.InvalidCert; - const sig_algo_body = der[pos .. pos + sig_alg.length]; + const sig_algo_body = try sliceChecked(der, pos, sig_alg.length); pos += sig_alg.length; const sig_oid = extractFirstOid(sig_algo_body) catch return Error.InvalidCert; // signatureValue BIT STRING const sig_bs = x509_parse.parseAsn1Tag(der, &pos) catch return Error.InvalidCert; if (sig_bs.tag != 0x03) return Error.InvalidCert; - if (pos + sig_bs.length > der.len) return Error.InvalidCert; - if (sig_bs.length == 0 or der[pos] != 0x00) return Error.InvalidCert; // unused-bits byte - const sig_der = der[pos + 1 .. pos + sig_bs.length]; + const sig_bs_body = try sliceChecked(der, pos, sig_bs.length); + if (sig_bs_body.len == 0 or sig_bs_body[0] != 0x00) return Error.InvalidCert; // unused-bits byte + const sig_der = sig_bs_body[1..]; var inner = TbsFields{}; - try parseTbsFields(der[tbs_body_start .. tbs_body_start + tbs_hdr.length], &inner, san_buf); + try parseTbsFields(tbs_body, &inner, san_buf); return .{ .tbs = tbs, @@ -112,8 +123,7 @@ fn extractFirstOid(seq_body: []const u8) ![]const u8 { var pos: usize = 0; const hdr = try x509_parse.parseAsn1Tag(seq_body, &pos); if (hdr.tag != 0x06) return Error.InvalidCert; - if (pos + hdr.length > seq_body.len) return Error.InvalidCert; - return seq_body[pos .. pos + hdr.length]; + return sliceChecked(seq_body, pos, hdr.length); } /// verify a leaf cert is signed by `ca_cert_pem`, is currently valid, and @@ -205,31 +215,31 @@ fn parseTbsFields(body: []const u8, out: *TbsFields, san_buf: *[max_san_uris][]c // issuer Name SEQUENCE const issuer_hdr = x509_parse.parseAsn1Tag(body, &pos) catch return Error.InvalidCert; if (issuer_hdr.tag != 0x30) return Error.InvalidCert; - out.issuer_cn = try extractCnFromName(body[pos .. pos + issuer_hdr.length]); + out.issuer_cn = try extractCnFromName(try sliceChecked(body, pos, issuer_hdr.length)); pos += issuer_hdr.length; // validity SEQUENCE { notBefore, notAfter } const validity_hdr = x509_parse.parseAsn1Tag(body, &pos) catch return Error.InvalidCert; if (validity_hdr.tag != 0x30) return Error.InvalidCert; var v_pos: usize = 0; - const validity_body = body[pos .. pos + validity_hdr.length]; + const validity_body = try sliceChecked(body, pos, validity_hdr.length); const nb_hdr = x509_parse.parseAsn1Tag(validity_body, &v_pos) catch return Error.InvalidCert; - out.not_before = try parseTimeBytes(validity_body[v_pos .. v_pos + nb_hdr.length], nb_hdr.tag); + out.not_before = try parseTimeBytes(try sliceChecked(validity_body, v_pos, nb_hdr.length), nb_hdr.tag); v_pos += nb_hdr.length; const na_hdr = x509_parse.parseAsn1Tag(validity_body, &v_pos) catch return Error.InvalidCert; - out.not_after = try parseTimeBytes(validity_body[v_pos .. v_pos + na_hdr.length], na_hdr.tag); + out.not_after = try parseTimeBytes(try sliceChecked(validity_body, v_pos, na_hdr.length), na_hdr.tag); pos += validity_hdr.length; // subject Name SEQUENCE const subject_hdr = x509_parse.parseAsn1Tag(body, &pos) catch return Error.InvalidCert; if (subject_hdr.tag != 0x30) return Error.InvalidCert; - out.subject_cn = try extractCnFromName(body[pos .. pos + subject_hdr.length]); + out.subject_cn = try extractCnFromName(try sliceChecked(body, pos, subject_hdr.length)); pos += subject_hdr.length; // subjectPublicKeyInfo SEQUENCE const spki_hdr = x509_parse.parseAsn1Tag(body, &pos) catch return Error.InvalidCert; if (spki_hdr.tag != 0x30) return Error.InvalidCert; - out.public_key_point = try extractEcPointFromSpki(body[pos .. pos + spki_hdr.length]); + out.public_key_point = try extractEcPointFromSpki(try sliceChecked(body, pos, spki_hdr.length)); pos += spki_hdr.length; // extensions [3] EXPLICIT — optional. there can be other optional fields @@ -238,9 +248,10 @@ fn parseTbsFields(body: []const u8, out: *TbsFields, san_buf: *[max_san_uris][]c while (pos < body.len) { const tag = body[pos]; const hdr = x509_parse.parseAsn1Tag(body, &pos) catch return Error.InvalidCert; + const ext_slice = try sliceChecked(body, pos, hdr.length); if (tag == 0xA3) { // [3] EXPLICIT extensions - try extractSanUrisFromExtensions(body[pos .. pos + hdr.length], out, san_buf); + try extractSanUrisFromExtensions(ext_slice, out, san_buf); } pos += hdr.length; } @@ -255,7 +266,7 @@ fn extractCnFromName(name_body: []const u8) Error![]const u8 { pos += rdn_hdr.length; continue; } - const rdn_body = name_body[pos .. pos + rdn_hdr.length]; + const rdn_body = try sliceChecked(name_body, pos, rdn_hdr.length); pos += rdn_hdr.length; var rp: usize = 0; @@ -265,18 +276,18 @@ fn extractCnFromName(name_body: []const u8) Error![]const u8 { rp += atv_hdr.length; continue; } - const atv_body = rdn_body[rp .. rp + atv_hdr.length]; + const atv_body = try sliceChecked(rdn_body, rp, atv_hdr.length); rp += atv_hdr.length; var ap: usize = 0; const oid_hdr = x509_parse.parseAsn1Tag(atv_body, &ap) catch return Error.InvalidCert; if (oid_hdr.tag != 0x06) continue; - const oid_bytes = atv_body[ap .. ap + oid_hdr.length]; + const oid_bytes = try sliceChecked(atv_body, ap, oid_hdr.length); ap += oid_hdr.length; if (!oidEquals(oid_bytes, &oid_common_name)) continue; const val_hdr = x509_parse.parseAsn1Tag(atv_body, &ap) catch return Error.InvalidCert; // utf8String (0x0C), printableString (0x13), or ia5String (0x16) are all fine. - return atv_body[ap .. ap + val_hdr.length]; + return sliceChecked(atv_body, ap, val_hdr.length); } } return Error.InvalidCert; @@ -290,8 +301,9 @@ fn extractEcPointFromSpki(spki_body: []const u8) Error![]const u8 { pos += alg.length; const bs = x509_parse.parseAsn1Tag(spki_body, &pos) catch return Error.InvalidCert; if (bs.tag != 0x03) return Error.InvalidCert; - if (bs.length == 0 or spki_body[pos] != 0x00) return Error.InvalidCert; // unused-bits byte - return spki_body[pos + 1 .. pos + bs.length]; + const bs_body = try sliceChecked(spki_body, pos, bs.length); + if (bs_body.len == 0 or bs_body[0] != 0x00) return Error.InvalidCert; // unused-bits byte + return bs_body[1..]; } fn extractSanUrisFromExtensions(exts_body: []const u8, out: *TbsFields, san_buf: *[max_san_uris][]const u8) Error!void { @@ -299,7 +311,7 @@ fn extractSanUrisFromExtensions(exts_body: []const u8, out: *TbsFields, san_buf: var pos: usize = 0; const seq = x509_parse.parseAsn1Tag(exts_body, &pos) catch return Error.InvalidCert; if (seq.tag != 0x30) return Error.InvalidCert; - const exts = exts_body[pos .. pos + seq.length]; + const exts = try sliceChecked(exts_body, pos, seq.length); var ep: usize = 0; while (ep < exts.len) { @@ -308,13 +320,13 @@ fn extractSanUrisFromExtensions(exts_body: []const u8, out: *TbsFields, san_buf: ep += ext.length; continue; } - const ext_body = exts[ep .. ep + ext.length]; + const ext_body = try sliceChecked(exts, ep, ext.length); ep += ext.length; var bp: usize = 0; const oid_hdr = x509_parse.parseAsn1Tag(ext_body, &bp) catch return Error.InvalidCert; if (oid_hdr.tag != 0x06) continue; - const oid_bytes = ext_body[bp .. bp + oid_hdr.length]; + const oid_bytes = try sliceChecked(ext_body, bp, oid_hdr.length); bp += oid_hdr.length; // skip optional critical BOOLEAN @@ -326,7 +338,7 @@ fn extractSanUrisFromExtensions(exts_body: []const u8, out: *TbsFields, san_buf: // extnValue OCTET STRING const octets = x509_parse.parseAsn1Tag(ext_body, &bp) catch return Error.InvalidCert; if (octets.tag != 0x04) continue; - const inner = ext_body[bp .. bp + octets.length]; + const inner = try sliceChecked(ext_body, bp, octets.length); if (oidEquals(oid_bytes, &oid_san)) { try fillSanUris(inner, out, san_buf); @@ -338,18 +350,21 @@ fn fillSanUris(san_octets: []const u8, out: *TbsFields, san_buf: *[max_san_uris] var pos: usize = 0; const seq = x509_parse.parseAsn1Tag(san_octets, &pos) catch return Error.InvalidCert; if (seq.tag != 0x30) return Error.InvalidCert; - const body = san_octets[pos .. pos + seq.length]; + const body = try sliceChecked(san_octets, pos, seq.length); var p: usize = 0; while (p < body.len) { const tag = body[p]; const hdr = x509_parse.parseAsn1Tag(body, &p) catch return Error.InvalidCert; + const uri = try sliceChecked(body, p, hdr.length); // [6] IMPLICIT IA5String → URI in CHOICE if (tag == 0x86) { - if (out.san_count < max_san_uris) { - san_buf[out.san_count] = body[p .. p + hdr.length]; - out.san_count += 1; - } + // fail closed rather than silently drop: if a cert lists more + // SAN URIs than we can hold, a required identity could sit past + // the cap and never match. reject the cert instead. + if (out.san_count >= max_san_uris) return Error.InvalidCert; + san_buf[out.san_count] = uri; + out.san_count += 1; } p += hdr.length; } @@ -518,3 +533,73 @@ test "rejects a tampered signature" { verifyLeafAgainstCa(alloc, pem_out.items, pair.ca_pem, null, test_now), ); } + +test "parseDer rejects truncations at every prefix length without panicking" { + const alloc = std.testing.allocator; + const pair = try mintCaAndLeaf(alloc); + defer alloc.free(pair.ca_pem); + defer alloc.free(pair.leaf_pem); + + const der = try pem_mod.parseCertDer(alloc, pair.leaf_pem); + defer alloc.free(der); + + // every truncation of a valid cert must return an error (never panic / + // OOB-slice). this is the core property the sliceChecked helper buys us. + var cut: usize = 0; + while (cut < der.len) : (cut += 1) { + var san_buf: [max_san_uris][]const u8 = undefined; + const result = parseDer(der[0..cut], &san_buf); + // a prefix of a real cert is never itself a valid cert. + try std.testing.expectError(Error.InvalidCert, result); + } +} + +test "parseDer rejects a cert whose inner length fields point past the buffer" { + const alloc = std.testing.allocator; + const pair = try mintCaAndLeaf(alloc); + defer alloc.free(pair.ca_pem); + defer alloc.free(pair.leaf_pem); + + const der = try pem_mod.parseCertDer(alloc, pair.leaf_pem); + defer alloc.free(der); + + // corrupt a single byte at a time across the first 64 bytes (the header / + // length region). none of these should ever panic — only return an error + // or, for benign value flips, parse. we only assert "no crash" by virtue + // of the test completing. + var i: usize = 0; + while (i < @min(der.len, 64)) : (i += 1) { + const mutable = try alloc.dupe(u8, der); + defer alloc.free(mutable); + mutable[i] +%= 0x40; // perturb length/tag bytes + var san_buf: [max_san_uris][]const u8 = undefined; + // ignore the result; the point is it returns rather than panics. + _ = parseDer(mutable, &san_buf) catch {}; + } +} + +test "fillSanUris fails closed when SAN count exceeds the cap" { + // build a SAN extension inner (SEQUENCE OF GeneralName) with max_san_uris+1 + // [6] IA5String URI entries, each a single 'x'. parseDer isn't exercised + // here — we drive fillSanUris directly with a hand-built octet string. + const alloc = std.testing.allocator; + var buf: std.ArrayList(u8) = .empty; + defer buf.deinit(alloc); + + const entry_count = max_san_uris + 1; + // inner contents: entry_count × (0x86 0x01 'x') = 3 bytes each + const inner_len = entry_count * 3; + try std.testing.expect(inner_len < 128); // single-byte DER length below + try buf.append(alloc, 0x30); // SEQUENCE + try buf.append(alloc, @intCast(inner_len)); + var n: usize = 0; + while (n < entry_count) : (n += 1) { + try buf.append(alloc, 0x86); // [6] IA5String + try buf.append(alloc, 0x01); // length 1 + try buf.append(alloc, 'x'); + } + + var out = TbsFields{}; + var san_buf: [max_san_uris][]const u8 = undefined; + try std.testing.expectError(Error.InvalidCert, fillSanUris(buf.items, &out, &san_buf)); +} From 165d769917f30fd4354e2b7e3fa5544cf6242432 Mon Sep 17 00:00:00 2001 From: Kacy Fortner Date: Tue, 23 Jun 2026 01:24:13 +0000 Subject: [PATCH 2/3] fix: bound the tls handshake read loops against unbounded peer flights MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit both the client (doHandshakeInner) and server (acceptClientAuthAndFinished) reassemble the peer's handshake flight into a growing buffer with no cap and no iteration limit — a malicious peer could stream handshake records forever, exhausting memory. cap each side's flight at 64 KB (real cert chains are a few KB) and fail the handshake past it. also widen the san_buf call sites to x509_verify.max_san_uris now that the cap moved to 32. --- src/tls/cli/service_cert_command.zig | 2 +- src/tls/client_session.zig | 14 +++++++++++++- src/tls/proxy/session_runtime.zig | 14 ++++++++++++-- 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/src/tls/cli/service_cert_command.zig b/src/tls/cli/service_cert_command.zig index 7565694..8727631 100644 --- a/src/tls/cli/service_cert_command.zig +++ b/src/tls/cli/service_cert_command.zig @@ -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; diff --git a/src/tls/client_session.zig b/src/tls/client_session.zig index 13f707f..3ebf907 100644 --- a/src/tls/client_session.zig +++ b/src/tls/client_session.zig @@ -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, @@ -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; @@ -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; diff --git a/src/tls/proxy/session_runtime.zig b/src/tls/proxy/session_runtime.zig index 244365f..def9034 100644 --- a/src/tls/proxy/session_runtime.zig +++ b/src/tls/proxy/session_runtime.zig @@ -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`. @@ -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; @@ -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]); @@ -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; From f7e3fc9217f55de323cc217015ca44b2d20e96ef Mon Sep 17 00:00:00 2001 From: Kacy Fortner Date: Tue, 23 Jun 2026 01:24:13 +0000 Subject: [PATCH 3/3] test: add fuzz-tls target for the x509 + handshake parsers the tls stack was the only attack-surface parser without a fuzz target (http/manifest/dns/cluster-msg/gossip-msg/wireguard already have one). fuzz-tls drives x509_verify.parseDer and the handshake message parsers on arbitrary bytes, asserting every returned slice stays within the input and nothing panics. --- build.zig | 17 ++++++++ src/test_fuzz_tls.zig | 96 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+) create mode 100644 src/test_fuzz_tls.zig diff --git a/build.zig b/build.zig index 74958fa..cc546af 100644 --- a/build.zig +++ b/build.zig @@ -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"); diff --git a/src/test_fuzz_tls.zig b/src/test_fuzz_tls.zig new file mode 100644 index 0000000..3fabbf1 --- /dev/null +++ b/src/test_fuzz_tls.zig @@ -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); +}