core: meta/veloxpart — resume sidecar, reader first + fuzzed (stage 5)
util/crc32.hpp — header-only CRC-32 (zlib polynomial, reflected), used to integrity-check the sidecar. meta/veloxpart — the <name>.veloxpart.meta resume file (docs/04 §5). Little-endian, versioned, CRC-32 over the whole record. Layout: magic, version, flags, total_size, downloaded, url set (original/effective/ mirrors), etag/last-modified/content-type, segment records (start, end INCLUSIVE, completed), optional sha256 streaming-hash blob. parse_veloxpart() is the attacker-facing surface (the file sits in a world-writable-ish download dir) and is total on any byte string: CRC checked before any field is interpreted; magic, a version it understands, every count and length bounded by a hard cap AND checked against the remaining buffer; ByteReader latches on overrun; trailing bytes rejected. Every malformation is meta_corrupt / meta_version_unsupported, never a crash or an unbounded allocation. serialize_veloxpart() is deterministic (unchanged sidecar isn't rewritten). File helpers write atomically (temp + rename) and fdatasync the file and its directory. Tests: crc32 known vector; full + minimal round-trips; deterministic serialize; file round-trip; and a truncation/corruption table — bad magic, CRC mismatch (payload and CRC-field flips), future version, truncation at every stage, hostile url_count / segment_count / lp_string length (the case the brief singles out), trailing bytes, impossible segment.completed. tools/fuzz/fuzz_veloxpart — feeds raw bytes and bytes-with-valid-CRC (so the field parser and ByteReader bounds checks are actually reached), and round-trip-stability-checks anything accepted. Ran 1.1M execs clean under ASan+UBSan+libFuzzer (clang++-21); fuzz_content_disposition and fuzz_url likewise re-run to 1.1M. tools/fuzz gains a -runs=0 seed-replay CTest smoke per target (regression tripwire; the campaign stays manual). Fuzz-found and fixed: parse_content_disposition could emit a filename containing NUL / control bytes from a mangled filename* ext-value — strip_path only removed path separators. Now sanitize_leaf() also drops C0 controls and DEL (rules/ still owns the authoritative sanitize; `..` and printable-unsafe content pass through as before). Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01HPPSGhiArbvQgwC2DNiURS
This commit is contained in:
@@ -19,24 +19,38 @@ endif()
|
||||
set(_core ${CMAKE_SOURCE_DIR}/core)
|
||||
set(_fuzz_flags -g -O1 -fsanitize=fuzzer,address,undefined -fno-omit-frame-pointer)
|
||||
|
||||
function(vdm_add_fuzzer name)
|
||||
# corpus_dir is the seed set (checked in, small, hand-named). It is passed read-only:
|
||||
# the CTest smoke run uses -runs so libFuzzer exits, and a real campaign is
|
||||
# `bin/<name> corpus/<x>/` by hand.
|
||||
function(vdm_add_fuzzer name corpus_dir)
|
||||
add_executable(${name} ${ARGN})
|
||||
target_include_directories(${name} PRIVATE ${_core}/include ${_core}/src)
|
||||
target_compile_features(${name} PRIVATE cxx_std_23)
|
||||
target_compile_options(${name} PRIVATE ${_fuzz_flags})
|
||||
target_link_options(${name} PRIVATE ${_fuzz_flags})
|
||||
|
||||
if(VELOX_BUILD_TESTS)
|
||||
# Regression tripwire only: replay the checked-in seeds once (-runs=0, no
|
||||
# mutation, no corpus writes) so a parser change that breaks a known-good or
|
||||
# known-hostile input fails CI in a fraction of a second. The 1M-exec bar
|
||||
# (AGENT-CORE M1 DoD) is a separate campaign job: `bin/<name> corpus/<x>/`.
|
||||
add_test(NAME ${name}_smoke
|
||||
COMMAND ${name} -runs=0 ${CMAKE_CURRENT_SOURCE_DIR}/${corpus_dir})
|
||||
set_tests_properties(${name}_smoke PROPERTIES LABELS "fuzz" TIMEOUT 60)
|
||||
endif()
|
||||
endfunction()
|
||||
|
||||
vdm_add_fuzzer(fuzz_content_disposition
|
||||
vdm_add_fuzzer(fuzz_content_disposition corpus/content_disposition
|
||||
content_disposition_fuzz.cpp
|
||||
${_core}/src/net/content_disposition.cpp
|
||||
${_core}/src/net/text_codec.cpp)
|
||||
|
||||
vdm_add_fuzzer(fuzz_url
|
||||
vdm_add_fuzzer(fuzz_url corpus/url
|
||||
url_fuzz.cpp
|
||||
${_core}/src/net/url.cpp
|
||||
${_core}/src/net/text_codec.cpp)
|
||||
|
||||
# Seed corpora live next to the harnesses.
|
||||
file(GLOB _cd_seeds ${CMAKE_CURRENT_SOURCE_DIR}/corpus/content_disposition/*)
|
||||
file(GLOB _url_seeds ${CMAKE_CURRENT_SOURCE_DIR}/corpus/url/*)
|
||||
vdm_add_fuzzer(fuzz_veloxpart corpus/veloxpart
|
||||
veloxpart_fuzz.cpp
|
||||
${_core}/src/meta/veloxpart.cpp
|
||||
${_core}/src/util/error.cpp)
|
||||
|
||||
@@ -14,9 +14,10 @@ extern "C" int LLVMFuzzerTestOneInput(const std::uint8_t *data, std::size_t size
|
||||
std::string_view header(reinterpret_cast<const char *>(data), size);
|
||||
auto cd = vdm::net::parse_content_disposition(header);
|
||||
|
||||
// Light invariants: a returned filename never contains a path separator or NUL.
|
||||
for (char c : cd.filename)
|
||||
if (c == '/' || c == '\\' || c == '\0')
|
||||
// Invariant: a returned filename has no path separator and no control byte (incl.
|
||||
// NUL / DEL). `..` and other printable-but-unsafe content is rules/'s to handle.
|
||||
for (unsigned char c : cd.filename)
|
||||
if (c == '/' || c == '\\' || c < 0x20 || c == 0x7F)
|
||||
__builtin_trap();
|
||||
|
||||
return 0;
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
VDMP
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,52 @@
|
||||
// Fuzz target for the .veloxpart.meta reader — the file AGENT-CORE §5 calls
|
||||
// attacker-adjacent (it lives in a world-writable-ish download directory). The reader
|
||||
// must be total: no crash, no over-read, no unbounded allocation, on ANY byte string.
|
||||
//
|
||||
// clang++ -std=c++23 -fsanitize=fuzzer,address,undefined ... (see CMakeLists.txt)
|
||||
// ./fuzz_veloxpart -max_len=8192 corpus/veloxpart/
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
#include "vdm/meta/veloxpart.hpp"
|
||||
#include "vdm/util/bytes.hpp"
|
||||
#include "vdm/util/crc32.hpp"
|
||||
|
||||
using vdm::ConstByteSpan;
|
||||
using vdm::meta::parse_veloxpart;
|
||||
using vdm::meta::serialize_veloxpart;
|
||||
using vdm::meta::VeloxPart;
|
||||
|
||||
namespace {
|
||||
|
||||
void check_roundtrip_stable(const VeloxPart &vp) {
|
||||
// A value the reader accepted must serialize and re-parse to an equal value —
|
||||
// otherwise the reader is accepting something the writer can't reproduce.
|
||||
auto image = serialize_veloxpart(vp);
|
||||
auto again = parse_veloxpart(ConstByteSpan(image.data(), image.size()));
|
||||
if (!again.has_value() || !(again.value() == vp))
|
||||
__builtin_trap();
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
extern "C" int LLVMFuzzerTestOneInput(const std::uint8_t *data, std::size_t size) {
|
||||
ConstByteSpan raw(reinterpret_cast<const std::byte *>(data), size);
|
||||
|
||||
// 1. Raw bytes straight in — most inputs die at the magic or CRC check.
|
||||
if (auto r = parse_veloxpart(raw); r.has_value())
|
||||
check_roundtrip_stable(r.value());
|
||||
|
||||
// 2. Same bytes with a valid CRC-32 appended, so the field parser is actually
|
||||
// reached and the ByteReader bounds checks (and load_le's precondition behind
|
||||
// them) get exercised on structurally-plausible-but-hostile input.
|
||||
std::vector<std::byte> with_crc(raw.begin(), raw.end());
|
||||
std::uint32_t c = vdm::crc32(raw);
|
||||
for (int i = 0; i < 4; ++i)
|
||||
with_crc.push_back(static_cast<std::byte>((c >> (8 * i)) & 0xFF));
|
||||
if (auto r = parse_veloxpart(ConstByteSpan(with_crc.data(), with_crc.size())); r.has_value())
|
||||
check_roundtrip_stable(r.value());
|
||||
|
||||
return 0;
|
||||
}
|
||||
Reference in New Issue
Block a user