curl-fuzzer
curl-fuzzer contains the fuzz targets, seed corpora, build integration, and testcase tools used by curl’s OSS-Fuzz project. It can also build standalone binaries for local regression testing and crash reproduction.
The repository has two target families:
- Structured fuzzers use typed protobuf scenarios, generated target-specific mutation roots, and bounded in-process protocol peers.
- Direct fuzzers feed raw bytes to focused parsers such as URL, DoH, netrc, and buffer-queue code.
Start with Getting started, then use the structured harness guide when changing a harness or adding protobuf seeds.
Online tools
The corpus decoder accepts structured
protobuf Scenario files and runs entirely in the browser. Selected testcase
data never leaves the device. For command-line protobuf decoding, see
Scenarios and corpora.
Sources of truth
Avoid copying lists that must be kept synchronized:
scripts/fuzz_targetsdefines the targets packaged for each architecture and sanitizer.schemas/curl_fuzzer.protois the complete annotated authoring schema. TypedSetOptionfields own native curl-option identity and profile membership; structured-field annotations own the remainder of each generated profile’s input surface.schemas/eligibility_options.protodefines the profile registry and eligibility metadata interpreted at build time. It does not define a second serialized Scenario format.corpora/contains checked-in direct-fuzzer inputs.scenarios/curl_fuzzer_proto/contains the reviewable structured seed sources.
Getting started
Prerequisites
The primary local workflow targets 64-bit Linux. It requires:
- Bash and common build tools;
- Clang and Clang++;
- CMake 3.24 or newer;
- Ninja or Make;
- Python 3.10 or newer;
- the Protocol Buffers compiler (
protoc) and well-known.protofiles (protobuf-compilerpluslibprotobuf-devon Debian and Ubuntu); - Git and network access for the first dependency build.
The build downloads and compiles curl and its static dependencies. It can take several minutes and use substantial disk space on its first run; later runs reuse the build tree.
Build all fuzzers
From the repository root, run:
./mainline.sh
This builds curl master and the fuzz targets with AddressSanitizer. Outputs are
placed under build/. The local build links a standalone replay engine and, on
the default non-MemorySanitizer path, runs the CTest suite.
To use an existing curl checkout:
./mainline.sh -c /path/to/curl
To build one CMake target instead of the aggregate fuzz target:
./mainline.sh -t curl_fuzzer_proto_http
MemorySanitizer builds use a separate directory and are compile/link checks:
SANITIZER=memory ./mainline.sh
Replay inputs
Standalone binaries accept files and directories. A file produces a detailed per-input trace; a directory is walked recursively:
FUZZ_VERBOSE=1 ./build/curl_fuzzer_proto_http \
build/generated_corpora/curl_fuzzer_proto_http/basic_get.scenario
./build/curl_fuzzer_proto_http \
build/generated_corpora/curl_fuzzer_proto_http/
Set FUZZ_VERBOSE to any value to enable libcurl’s verbose output. Structured
targets use generated binary seeds under build/generated_corpora/.
Run mutation fuzzing
mainline.sh produces replay binaries by default; it does not begin a mutation
fuzzing campaign. The most production-like local route uses the
OSS-Fuzz helper:
git clone --depth 1 https://github.com/google/oss-fuzz.git .oss-fuzz
python3 .oss-fuzz/infra/helper.py build_image curl
python3 .oss-fuzz/infra/helper.py build_fuzzers \
--sanitizer address --engine libfuzzer curl "$PWD"
mkdir -p build/local-corpus/curl_fuzzer_proto_http
python3 -m zipfile -e \
.oss-fuzz/build/out/curl/curl_fuzzer_proto_http_seed_corpus.zip \
build/local-corpus/curl_fuzzer_proto_http
python3 .oss-fuzz/infra/helper.py run_fuzzer \
--corpus-dir "$PWD/build/local-corpus/curl_fuzzer_proto_http" \
curl curl_fuzzer_proto_http
Pass libFuzzer arguments after the target name when needed. The helper keeps the compiler, sanitizer, engine, and packaging behavior aligned with OSS-Fuzz.
Architecture
Every packaged structured target has a same-named source file under
fuzzer_entrypoints/.
These thin files give OSS-Fuzz and Fuzz Introspector an unambiguous target
identity while sharing the substantial harness implementation. Direct targets
define their entrypoints in their top-level implementation files instead.
| Target family | Shared implementation | Input model |
|---|---|---|
curl_fuzzer_proto* | proto_fuzzer/, generated profile Traits, ordinary profile descriptors, and generated protobuf sources | Target-specific generated message lowered to an internal Scenario |
fuzz_url, fuzz_bufq, fuzz_doh, fuzz_netrc, fuzz_private | Dedicated source files | Raw target-specific bytes |
The normal data flow is:
seed corpus -> fuzzer engine -> target entrypoint -> shared harness -> libcurl
Each structured target asks libprotobuf-mutator to operate on its generated
restricted message type, lowers that message into the common internal runtime
Scenario, applies semantic normalization and bounds, then runs it against an
appropriate bounded in-process peer. The compatibility target has a generated
broad root and retains its mixed execution behavior. Direct targets skip the
transfer harness and exercise a narrow parser or data structure.
fuzz_private remains input-driven despite sharing one executable across
several small surfaces. Its first byte selects ptrarray, dynhds,
uint-spbset, memdebug, or cf-ip-happy; the remaining bytes select bounded
operations, arguments, and ordering. Each lane releases all per-input state
before the input returns. In particular, the memdebug lane never enables global
logging or allocation limits, and the Happy Eyeballs lane never opens a remote
connection.
Build-time generation
The structured build derives several artifacts from the checked-in annotated schema:
- Host
protocemits a descriptor set containing the schema, eligibility metadata, imports, and source locations. - The schema-eligibility generator validates the complete profile registry
and every annotated field. For typed
SetOptionfields it also verifies the declaredCURLOPT_*name and protobuf type against the selected curl headers. - That one model emits a metadata-free full authoring view, the generic internal runtime schema and native option dispatch, one restricted schema per profile, direct lower/lift/pruning Traits, and deterministic reports.
protocgenerates the internal runtime C++ type and each target’s selected profile type. Only the selected profile object is linked into that target.- Checked-in textprotos are encoded as the full authoring
Scenariowith ordinaryprotoc --encode, then packaged into one seed archive per target.
Restricted roots retain the authoring field numbers and wire types. They can therefore parse the ordinary authored seed bytes directly; unsupported fields become unknown fields and are discarded before lowering. Conditional eligibility is pruned after selector normalization and before shape bounds. This relationship applies to the new schema and its generated views, not to pre-cutover binary corpora.
Schema annotations own option identity, value kind, and static or conditional profile membership. Runtime C++ continues to own protocol mechanics, selector normalization, work limits, curl object lifetimes, and local transport safety.
Conditional targets
scripts/fuzz_targets
is the canonical packaged target list. The base structured suite is included
in OSS-Fuzz’s AddressSanitizer/i386 build. The GnuTLS, Mbed TLS, and HTTP/3
variants are omitted from i386 and MemorySanitizer builds because their
complete dependency stacks have not been validated for those configurations.
Corpus management
Checked-in seeds
Direct-fuzzer seeds are binary files under corpora/<target>/.
Structured seeds are authored as textproto under
scenarios/curl_fuzzer_proto/<lane>/; their generated binary form belongs in
the build tree and must not be checked in.
When the aggregate build runs, CMake writes a manifest for each generated structured corpus. Packaging and coverage use that manifest so removed scenarios cannot survive as stale build artifacts.
Public OSS-Fuzz corpora
Download the current public corpus for every supported target with:
./scripts/download_public_corpus.sh
The script writes to ossfuzz_corpus/<target>/, skips targets whose public
archive is not available yet, and leaves an existing non-empty directory alone.
Force a refresh with:
./scripts/download_public_corpus.sh -f
OSS-Fuzz prefixes target names that do not already begin with curl_. The
download script owns this mapping; documentation should not maintain a parallel
list of storage URLs.
Downloaded corpora are ignored by Git. Coverage automatically includes eligible corpora when present. Structured targets replay only their own corpus and exact same-profile aliases such as the three HTTPS backend variants. Pre-cutover generic-option bytes belong to the revision and target that produced them, and cross-profile reuse is not inferred from parse success.
Automatic structured replay additionally requires this marker at the public corpus root:
ossfuzz_corpus/.structured-schema-owned-eligibility-v1
The download script intentionally does not create it: the public ZIP format does not identify the schema that produced its entries. Only after the upstream structured corpora have been replaced for the typed-option cutover, move any existing local root aside, download a fresh root, and create the marker:
mv ossfuzz_corpus ossfuzz_corpus.pre-schema-owned-eligibility
./scripts/download_public_corpus.sh
touch ossfuzz_corpus/.structured-schema-owned-eligibility-v1
Coverage, benchmarking, profiling, and HTTP/2 default bootstrap discovery then accept structured directories from that root. Explicit corpus overrides remain explicit caller assertions. Do not mark a root that retains pre-cutover structured directories.
Seed archives
scripts/create_zip.sh is used by the OSS-Fuzz build to package seed corpora.
For structured targets it reads the generated manifest; for direct targets it
archives the corresponding checked-in corpus directory. Per-target runtime
settings live under ossconfig/.
Structured fuzzers
The structured fuzzer suite models a curl transfer with one annotated protobuf authoring schema. The build derives a restricted root for each target. libprotobuf-mutator mutates that target-specific type rather than an unstructured byte stream, and a generated adapter lowers it into the common runtime before the harness runs curl against a bounded local peer.
The targets still consume ordinary binary protobuf files. Textprotos under
scenarios/curl_fuzzer_proto/ are readable full-authoring seed sources; CMake
encodes them under build/generated_corpora/.
How an input reaches curl
flowchart TD
subgraph build["Build time"]
sources["Annotated authoring schema<br/>eligibility metadata<br/>selected curl.h"]
descriptor["Descriptor set"]
generator["Validate one eligibility model"]
authoring["Metadata-free authoring schema"]
runtimeSchema["Internal runtime schema<br/>and option manifest"]
profileSchema["Restricted profile schema<br/>and direct Traits adapter"]
seeds["Checked-in .textproto seeds"]
encoder["ordinary protoc --encode<br/>authoring Scenario"]
corpus["Per-target .scenario corpus"]
target["Structured fuzzer executable<br/>linked to one generated profile"]
sources --> descriptor --> generator
generator --> authoring
generator --> runtimeSchema
generator --> profileSchema
authoring --> encoder
seeds --> encoder --> corpus
runtimeSchema --> target
profileSchema --> target
end
subgraph runtime["Each fuzz or replay iteration"]
input["Binary profile-compatible input"]
lpm["libprotobuf-mutator<br/>generated profile root"]
lower["Direct lower to internal Scenario"]
fixed{"Fixed profile?"}
normalize["Normalize selectors<br/>generated prune<br/>bound surviving shape"]
runner["Scenario runner"]
curl["curl"]
peer["Harness-owned local peer"]
input --> lpm --> lower --> fixed
fixed -->|yes| normalize --> runner
fixed -->|compatibility| runner
runner --> curl
runner --> peer
curl <-->|bounded protocol exchange| peer
end
corpus --> input
target --> input
SetOption is an ordered oneof of typed fields. Each field declares its stable
protobuf tag, native CURLOPT_* identity, and profile membership beside the
value. Structured fields carry the same kind of eligibility metadata. The
generator checks those declarations against the selected curl checkout’s
curl.h, then derives the internal CurlOptionId, value-kind manifest,
profile cases, and direct transfers. There is no parallel handwritten C++
option allowlist.
The metadata-free authoring view is staged at
build/schemas/curl_fuzzer.proto. Normal checked-in textprotos are encoded
against that full view with ordinary protoc --encode. Generated restricted
roots keep the same field numbers and wire types, so a target can parse those
bytes directly and discard unknown fields. No separate per-profile seed encoder
is required.
Each thin entrypoint binds its generated Traits once into an ordinary kProfile
descriptor and passes that descriptor to non-template entrypoint helpers.
Mutation, crossover, and loading therefore use the same concrete message type
without exposing template specializations to Fuzz Introspector. Static profile
projection prevents unsupported fields from entering the mutation graph.
Generated conditional pruning runs after selector
normalization and before limits, while C++ continues to own scheme fixing,
selector canonicalization, work bounds, resource lifetimes, and peer behavior.
The compatibility target uses a generated broad root and keeps its historical
mixed execution mode; the schema format itself still changed in place.
Pre-cutover binaries are not automatically detected or loaded. Reproduce them with the revision and target that created them.
The peers are owned by the harness. Stream protocols generally use local socket pairs; TFTP and HTTP/3 use private loopback UDP endpoints. Baseline curl configuration disables ambient proxies, restricts protocols, redirects direct connections to the harness, and uses short timeouts. A new target or option must preserve those isolation guarantees.
Build and replay a scenario
Build one structured target and its generated seed corpus:
./mainline.sh -t curl_fuzzer_proto_http
Replay one seed with the standalone runner:
./build/curl_fuzzer_proto_http \
build/generated_corpora/curl_fuzzer_proto_http/basic_get.scenario
The standalone executable accepts files and directories, so an entire target-specific seed set can be replayed too:
./build/curl_fuzzer_proto_http \
build/generated_corpora/curl_fuzzer_proto_http/
Set FUZZ_VERBOSE=1 when a reproduction needs curl’s protocol trace. These
locally built executables replay inputs; OSS-Fuzz builds provide the active
libFuzzer mutation engine.
Continue with Writing and inspecting scenarios, then consult Target profiles before choosing a corpus or adding a field. The C++ reference documents the implementation’s classes, functions, and ownership contracts.
Writing and inspecting scenarios
The checked-in scenario sources live below scenarios/curl_fuzzer_proto/ and
use protobuf’s text format. They are the source of truth for seed inputs. Do
not check generated .scenario files into corpora/; CMake writes those
derived binary files to build/generated_corpora/<corpus-name>/. The corpus
name normally matches the target. The GnuTLS and mbedTLS HTTPS variants reuse
build/generated_corpora/curl_fuzzer_proto_https/; the canonical mapping is
in scripts/fuzz_corpus_helpers.sh.
A minimal scenario
scheme: SCHEME_HTTP
host_path: "127.0.0.1/basic"
connection {
initial_response: "HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello"
}
scheme and host_path form the request URL. Most byte-bearing fields use
protobuf bytes, so textproto escape sequences can represent arbitrary wire
data. SetOption is an ordered oneof whose field names and protobuf types
identify the curl options directly. At most one option can be selected in each
entry, and oneof presence preserves explicit false, zero, and empty values:
options {
nobody: true
}
The typed field carries build-time metadata naming CURLOPT_NOBODY and listing
the profiles allowed to retain it. There is no separate option ID or value-kind
field in authored input. Repeating options preserves setopt order and
duplicates.
The main groups of fields are:
- Request configuration:
options,request_headers,mime_post,upload, andtelnet_options. - Peer work:
connection,subsequent_connections, structured WebSocket frames, andhttp3_plan. - Focused lanes:
api_plan,multi_planand its closed state-probe profile,resolve_entries,happy_eyeballs,doh, TLS certificate selection, HTTPS/H2 key-exchange selection, bounded HTTPS client-credential and handshake profiles,pop3_starttls_filter_allocation_failure,ftp_auth_tls_filter_allocation_failure,gopher_probe_mode,mqtt_probe_mode,smtp_probe_mode,socks_proxy_mode,haproxy_protocol,http_transport_mode,proxy_socket_option_mode,trace_ids,probe_multi_pollset,resolver_mode, filename-backed parser inputs, andfail_allocations.
Read the comments in schemas/curl_fuzzer.proto for the complete field
contract. It is the annotated authoring schema: typed option fields declare
their native curl identity and profile membership, and structured fields
declare their own eligibility. During the build, the schema-eligibility
generator validates those declarations against the selected curl checkout’s
curl.h, emits restricted target roots, and stages a metadata-free authoring
view at build/schemas/curl_fuzzer.proto for ordinary protoc and browser
tools.
Peer scripts depend on the target
For the ordinary HTTP peer, connection.initial_response is sent when curl
opens the socket and each on_readable value is released on a later readable
turn. subsequent_connections supplies bounded scripts for redirects,
authentication retries, and other fresh HTTP sockets.
The deep HTTP lane can instead select a fixed plaintext forward proxy, a fixed
HTTP/1.1 CONNECT proxy, or a fixed abstract Unix-domain origin with
http_transport_mode. Proxy traffic is always directed to a numeric loopback
endpoint and every socket is replaced by the in-process peer; Unix mode uses a
harness-owned abstract name and creates no filesystem entry. Use
request-triggered on_readable chunks for these modes. A CONNECT script puts
the tunnel response first and the origin response second. The correlated
haproxy_protocol CONNECT profile instead sends the bounded request headers to
both the proxy and origin and applies fixed bounded send pressure. The long
valid CONNECT request, small peer drains, and repeated pollset probes make the
pending fixed IPv6 HAProxy v1 setup state reachable without allowing the
scenario to select either endpoint.
The deep HTTP lane’s proxy_socket_option_mode selects a closed set of option
paths. Fixed hostname, IPv4-CIDR, and IPv6-CIDR lists exercise
CURLOPT_NOPROXY; a scoped http_proxy override points only at a fixed
numeric loopback endpoint intercepted by the socketpair callback and restores
the previous environment afterwards. The interface/keepalive mode returns an
unconnected INET socket only for its owned ephemeral loopback listener, binds
it with host!127.0.0.1, and applies fixed TCP keepalive values. Mutations
cannot supply an environment value, interface, bind address, proxy endpoint,
or destination port.
Other peers intentionally give those fields narrower meanings:
- WebSocket scenarios use a peer that generates the
101handshake and accepts either raw chunks or structuredserver_frames. The WSS lane runs that same Upgrade, framing, callback, and manual-drive behavior after a verified in-process TLS handshake. ForCURLOPT_CONNECT_ONLY=2, the peer coalesces the first bounded frame with the101response so libcurl must preserve already-read payload forcurl_ws_recv; later chunks are delivered by the manual-drive tail. - The dedicated HTTPS peer encrypts connection-script bytes as HTTP
application data. Its optional
tls_client_credential_profileselects only harness-owned malformed blobs or fixed/dev/nullengine/provider probes; protobuf bytes never become a filename, engine name, or provider name.pinned_public_key_fileinstead exposes bounded protobuf bytes through a session-owned anonymous descriptor and installs only its/proc/self/fdpath, allowing curl’s filename-backed PEM pin parser to run without host-file access. The separatetls_handshake_profilecan replace the OpenSSL peer with a fixed pre-handshakeclose_notifyor empty HelloRequest record. It covers curl’s terminal error translation and message classification without accepting fuzz-selected TLS bytes; GnuTLS and mbedTLS clients treat those profiles as the ordinary verified peer. The compatibility target preserves its older behavior and treats HTTPS script bytes as raw TLS records. These controls deliberately leaveCurl_ssl_cfilter_addto a protocol peer capable of an in-place STARTTLS upgrade. Process-lifecycle probes forossl_cleanup,Curl_ssl_cleanup,Curl_ssl_version, andCurl_tls_keylog_closebelong in the one-shot API lane instead of a per-input HTTPS path. - TELNET preloads a bounded response before entering curl’s blocking protocol loop.
- FTP treats the primary connection as command-aligned control replies and
follow-on connections as data streams. An
SCHEME_FTPSscenario runs the identical command and passive-data exchange over implicit TLS: the control connection and each EPSV/PASV data connection complete a verified handshake, and the script must answer curl’sPBSZ 0andPROT Pcommands after login. Its fixed authority istls.test, matching the checked-in trust anchor, and the handshakes carry no ALPN extension. Active mode is not available in this lane because it would require wrapping a real TCP listener rather than a socketpair;CURLOPT_FTPPORTis removed from FTPS inputs. The plaintext-onlyftp_auth_tls_filter_allocation_failureselector accepts AUTH SSL and fails the adjacent TLS-filter allocation without relying on a build-specific allocation ordinal. - TFTP preserves packet boundaries: the initial response and each readable chunk are separate datagrams.
- SMTP uses the primary connection as command-aligned replies. The initial
response is the 220 greeting, each readable chunk answers the next command,
and after a 354 DATA reply the peer drains the dot-stuffed message body up to
its
\r\n.\r\nend marker before releasing the final 250. Envelope recipients come from the schema-nativemail_rcptlist becauseCURLOPT_MAIL_RCPTis acurl_slist; the URL path supplies curl’s EHLO domain rather than the recipient in current libcurl. AnSCHEME_SMTPSscenario runs the identical exchange over implicit TLS; its fixed authority istls.test, matching the checked-in trust anchor, and the handshake carries no ALPN extension. Plaintext SMTP may select a closedsmtp_probe_modefor optional or required STARTTLS, the adjacent TLS-filter allocation failure, or one fixed command-send backpressure shape. Accepted STARTTLS probes end the plaintext peer with bounded EOF; implicit SMTPS remains the lane’s real successful TLS carrier. - IMAP uses the primary connection as command-aligned replies. The initial
response is the
* OKgreeting, each readable chunk answers the next command, and a status line beginning withOK,NO,BAD, orPREAUTHreceives curl’s generated command tag. A{N}literal copies the next N bytes verbatim, so FETCH bodies and their tagged completion can share one chunk. An accepted APPEND continuation drains exactly the announced literal size. AnSCHEME_IMAPSscenario runs the identical exchange over implicit TLS; its fixed authority istls.test, matching the checked-in trust anchor, and the handshake carries no ALPN extension. IMAP may retain a bounded receive-buffer size to exercise long-command short writes; drain throttling is always cleared. - POP3 also uses command-aligned replies, but its
+OK/-ERRstatus lines need no rewriting. Multi-line CAPA, LIST, and RETR replies include their terminating dot line and are written unchanged. AnSCHEME_POP3Sscenario runs the identical exchange over implicit TLS against the sametls.testauthority and also negotiates no ALPN protocol. POP3 may retain the same recv-buffer-only pressure. Its semantic STLS selector arms one failure immediately after the peer accepts STLS, so it targets filter installation without depending on earlier allocation ordinals. - Gopher normally writes one bounded selector through the stream peer. Its closed probe selector can split that write with no operation deadline, fill the harness-owned socket before a five-millisecond timeout, or select a fixed Gophers authority that reaches the TLS session-key size limit. No protobuf bytes control the environment value, timeout, or oversized host.
- The proxy-transport lane uses request-triggered chunks for each SOCKS reply
and then for tunneled HTTP. SOCKS5 advertises deterministic no-auth and
username/password methods; the scripted method-selection byte chooses the
path.
haproxy_protocolinserts a protocol-v1 prefix after tunnel setup with fixed loopback metadata, never a fuzz-selected endpoint. - SMTP, IMAP, POP3, FTP, FTPS, MQTT, MQTTS, the proxy-transport lane, and deep
HTTP CONNECT retain the opt-in
probe_multi_pollsetfield. When it is true, their drivers call zero-timeoutcurl_multi_pollafter successful perform turns while both the transfer and protocol connection are live. This reaches protocol/filter pollset callbacks without delaying the default false path. Other fixed target policies, including direct, forward-proxy, and Unix-domain deep HTTP, clear the field until their loops have a useful intermediate state to probe. - MQTT treats the primary connection as a binary packet stream. The initial
response is the CONNACK, while each readable chunk is an opaque
server-initiated packet (for example PUBLISH followed by a server
DISCONNECT) released after the peer acknowledges curl’s SUBSCRIBE. Client
packets are delimited by MQTT’s fixed header and base-128 remaining length,
not by newlines; the URL path becomes the publish/subscribe topic and
CURLOPT_POSTFIELDSsupplies the publish payload. AnSCHEME_MQTTSscenario runs the identical packet exchange over implicit TLS; its fixed authority istls.test, matching the checked-in trust anchor, and the handshake carries no ALPN extension. Its closed probe selector can publish a fixed 16 KiB body through a small, incrementally drained socket or install a fixed 65,536-byte username or password. This reaches queued-send and 16-bit credential-limit handling without placing oversized values in the protobuf corpus. - DICT uses the primary connection for one request/response exchange. curl
writes a
CLIENTidentification line, a command derived from the URL path (DEFINEfrom/d:word,MATCHfrom/match:word:db,SHOWfrom/show:db, or a raw command with colons replaced by spaces), andQUITin a single send. It then reads the response as an unframed text stream, so the first non-CLIENT/non-QUITon_readableentry is the complete body and the peer half-closes after writing it. Include the RFC 2229.\r\nterminator in the seed when a well-formed body is desired; curl does not parse it and treats EOF as end-of-transfer.initial_responseis normally empty because DICT has no server greeting. - FILE has no peer. Its generated schema omits
host_pathand retains boundedfile_sourcebytes plus upload scripts and their scalar controls. The runtime places the source in a session-owned anonymous regular file and constructs the completefile:///proc/self/fd/NURL. Mutations therefore exercise curl’s local read, write, range, resume, time-condition, metadata, and upload rate-limit paths without reading or writing an arbitrary host path. - LDAP is BER/ASN.1, not line-oriented.
initial_response, when present, is the scripted bind reply and is released only after curl’s bindRequest with its envelope messageID rewritten to the request id; an empty value asks the peer to synthesize a successful bind. Each readable chunk is one complete server LDAPMessage (for example a searchResEntry) forwarded after the searchRequest and likewise renumbered. A scripted searchResDone ends the search; otherwise the peer synthesizes one. When login options request SASL, the peer recognizes OpenLDAP’s root-DSEsupportedSASLMechanismssearch, advertises bounded PLAIN support, and preserves the readable script for the later URL-derived search. The URL path carries the base DN, attributes, scope, and filter, because this curl revision’s OpenLDAP handler does not consultCURLOPT_CUSTOMREQUEST. AnSCHEME_LDAPSscenario runs the identical BER exchange over implicit TLS. In this curl revision OpenLDAP itself is built without TLS:lib/openldap.cdrives curl’s SSL connection filter and routes OpenLDAP’s sockbuf I/O through curl’s send/receive callbacks, so the in-process peer terminates ordinary TLS records and then parses decrypted LDAPMessages. The fixed authority istls.test, matching the checked-in trust anchor, and the handshake carries no ALPN extension. A plaintextSCHEME_LDAPscenario may instead setldap_starttls: the peer recognizes OpenLDAP’s ExtendedRequest and returns a successful correlated ExtendedResponse before curl installs its TLS filter. The peer intentionally closes the unimplemented upgraded carrier, keeping this transition bounded. - RTSP is request-aligned and has no server greeting. The peer consumes each
request start line and header block, releases the next
on_readableentry only after the terminating empty line, and drains any announcedContent-Lengthbody exactly. A response chunk may carry the status line, headers, an SDP/parameter body, and RFC 2326 interleaved RTP frames ($, channel, 16-bit length, payload).CURLOPT_RTSP_REQUESTselects the method, and the response’s own CSeq stays mutation-controlled so mismatches reach curl’sCURLE_RTSP_CSEQ_ERRORpath. Closed probe modes repeat OPTIONS first at the same origin and then at a fixed second origin to exercise cached-connection and learned-Session handling, or split an invalid RTP channel at a fixed receive boundary and fail only the resulting one-byte body write. - IPFS and IPNS are gateway schemes rather than wire protocols. libcurl has no
ipfs://handler; curl’s command-line tool rewritesipfs://<cid>andipns://<name>to<gateway>/ipfs/<cid>or<gateway>/ipns/<name>before issuing an ordinary HTTP request. The structured lane mirrors that rewrite against a fixed loopback gateway and the in-process HTTP peer.host_pathholds a pinned CID/name followed by the mutation-controlled gateway path, query, and fragment; the runner prepends the gateway authority. - The resolver lane’s
dohsettings bind a loopback DoH responder and pointCURLOPT_DOH_URLat it.transport_modepreserves the historical HTTP endpoint by default or selects an HTTPS endpoint backed by a dedicated trust-anchor-verified HTTP/1.1 TLS peer. Normal response mode answers address and HTTPS queries; its HTTPS record carriesalpn=h2,h3, so the separate outer HTTPS transfer completes through the in-process TLS/ALPN h2 peer usinghttp2_plan. Malformed-DNS mode returns HTTP 200 with a one-byte DNS body, HTTP-error mode returns a non-success status before curl can decode DNS bytes, and transport-error mode closes only after receiving a complete DoH request so curl records the internal transfer failure. Non-defaultoption_probe_modevalues force HTTPS and install one closed, harness-owned parent-option fixture: a non-global diagnostic stream, procfs-backed CAINFO, CAPATH, and CRLFILE pathnames, or a no-op SSL_CTX callback and data pointer. Separate modes keep the deliberately invalid pathname fixture from masking the callback’s successful end-to-end propagation.CURLOPT_RESOLVEis cleared because it cannot carry SVCB/HTTPS records. A paired coverage seed repeats that exact exchange withtrace_config: "all"; tracing is reset at the input boundary and does not become a resolver-lane default. An ECH seed releases the address answers first and delays the HTTPS answer by one deterministic drive turn, covering TLS setup while the record is pending without relying on scheduling or wall-clock timing. - The resolver lane’s non-default
resolver_modevalues select fixed, target-owned states. Positive and negative threaded lookups, plus a scope-less link-local result and AF_UNSPEC retry, use only the resolver binary’s localgetaddrinfobackend. The threaded modes receive bounded poll/wait turns; the partial backend intentionally staggers A behind AAAA so curl consumes a live partial result instead of relying on scheduler luck. Queued-cancellation mode limits the public resolver pool to one worker, holds its first lookup while a duplicated easy queues later work, cancels that duplicate, then observes the worker’s publicTHREADSidle trace after releasing the gate. The observation proves entry to the configured timed condition wait without using elapsed time as the coverage contract. Unix-socket mode passes/curl-fuzzer-resolver.sockto curl’s address builder, but the open-socket callback replaces it with an anonymous peer and never opens that filesystem path. DoH error settings enable silent typed diagnostics, distinguishing DNS decode failures from HTTP failures without writing per-input logs. The global-lifecycle mode finishes a localhost transfer, calls the public global cleanup entrypoint only after all resolver workers are gone, and reinstalls the harness allocator callbacks before another input runs. - HTTP/2 origin lanes treat connection bytes as raw HTTP/2 frames after a
fixed prior-knowledge or TLS/ALPN setup. The HTTPS CONNECT proxy does the
same when
connection.proxy_protocolis zero; its explicit HTTP/1 profile instead treats the script as a bounded CONNECT response and always uses a tunnel. The H2 profile can select either CONNECT tunnelling or direct absolute-form forwarding withh2_forward_proxy. The fast HTTP lane retains the HTTP/1.1 h2c Upgrade request and101transition instead. - Direct HTTP/3 uses ordered
http3_planactions after a valid QUIC/TLS handshake and discards the ordinary connection script. Settinghttp3_plan.use_h1_connect_udp_proxyinstead usesconnectionfor a local HTTP/1.1 CONNECT-UDP upgrade and capsule bytes; direct HTTP/3 actions are cleared.
Use a seed already assigned to the intended target as the closest example. A valid protobuf message can still be irrelevant to a lane if that lane’s policy removes the field or replaces its scheme.
Select allocation failures
All structured targets can reject selected libcurl allocation attempts while driving a transfer:
fail_allocations: 5
fail_allocations: 9
Ordinals are one-based and count positive-size calls on the fuzzer thread
through the callbacks installed by curl_global_init_mem. Scenario parsing
stays outside the sequence; libcurl setup, transfer driving, result probes,
and cleanup inside the shared scenario runner are included except for narrow
harness ownership scopes. The COPYPOSTFIELDS ownership commit and
pushed-handle cleanup enumeration neither fail nor advance the ordinal counter,
so failures remain pending for subsequent injectable allocations. Fixed target
policies retain the four smallest distinct values after folding them into
1..4096; the compatibility target applies that bound at execution time without
rewriting the message. State-dependent probes still require the preceding
transfer entrypoint to establish their documented runtime state.
In the API target, a scenario that combines API_DRIVE_EASY_EVENTS with an
allocation plan is normalized to API_DRIVE_EASY_PERFORM. After its
heap-backed socket-monitor allocation fails, curl’s current debug event adapter
returns a status outside the socket-callback contract; event-based driving
remains available when no allocation failure is selected.
An active plan can combine a MIME body with CURLOPT_CRLF or
CURLOPT_TRANSFERTEXT, allowing allocation injection across curl’s composed
MIME and line-conversion reader stack. curl clears partially installed readers
when that setup fails.
Multiple failures describe one evolving execution. In the example, 9 means the ninth allocation attempt on the path produced after attempt 5 failed, not the ninth attempt from a failure-free baseline. Keep failure seeds with the target that produced them: allocation sequences can change with the target, curl revision, build configuration, and TLS backend. Resolver and peer worker threads deliberately do not inherit the calling thread’s plan.
Add a seed
- Put a descriptively named
.textprotofile in the closest protocol directory underscenarios/curl_fuzzer_proto/. Its filename stem must be unique across every source directory combined into the destination corpus, because generated corpora are flat. - Check the corpus declarations in
CMakeLists.txt. Directory membership is not the complete assignment rule: the fast HTTP set is explicit, the deep and compatibility sets combine selected directories, and the timing set is selected by abackpressurefilename pattern. - Build the destination target. CMake encodes the full authoring
Scenariowith ordinaryprotoc --encodeand creates<name>.scenarioin the corpus assigned to that target. The target’s generated root preserves retained field tags and discards fields outside its profile when it parses the file. - Decode the generated file and replay it through the matching executable.
For example:
./mainline.sh -t curl_fuzzer_proto_http
uv run read_proto_corpus \
build/generated_corpora/curl_fuzzer_proto_http/basic_get.scenario
FUZZ_VERBOSE=1 ./build/curl_fuzzer_proto_http \
build/generated_corpora/curl_fuzzer_proto_http/basic_get.scenario
Adding a new fast-HTTP seed also requires updating its explicit list and count
check in CMakeLists.txt.
Decode a binary input
For interactive inspection, use the published
corpus decoder. It recognizes protobuf Scenario files
and reads the selected file entirely in the browser. The decoder embeds the
metadata-free full authoring view prepared from the annotated schema, so it can
inspect any current profile payload without loading eligibility annotations.
read_proto_corpus wraps protoc and prints named textproto fields using the
checked-in schema. It falls back to the staged build copy when the source
schema is unavailable:
uv run read_proto_corpus path/to/crash-input
uv run read_proto_corpus \
--proto-file build/schemas/curl_fuzzer.proto path/to/crash-input
The command requires protoc on PATH. It checks --proto-file, then
CURL_FUZZER_PROTO, the checkout’s schemas/curl_fuzzer.proto, and finally
build/schemas/curl_fuzzer.proto. Without a schema it falls back to
wire-level field numbers; request that form explicitly with --raw:
uv run read_proto_corpus --raw path/to/crash-input
Use the schema from the curl-fuzzer revision that produced the input. The typed
option cutover changed the existing format in place: pre-cutover generic-option
binaries can parse misleadingly under a current schema and are not detected
automatically. Reproduce them with their original revision and the target that
produced them. Current checked-in seeds are ordinary authoring
textprotos encoded directly by protoc; there is no separate per-profile seed
encoder.
Target profiles
All structured targets are generated from one authoring schema and share most of the runtime, but each target gives libprotobuf-mutator its own restricted message type. Retained fields keep the authoring field numbers and wire types, so ordinary authored seed bytes remain profile-compatible. A profile defines the mutation surface, peer, semantic normalization, and work budget.
| Target | Profile and peer focus |
|---|---|
curl_fuzzer_proto | Broad generated compatibility profile for mixed HTTP, HTTPS, WebSocket, and TELNET behavior. It preserves the historical execution mode, but uses the new typed-option schema like every other target. |
curl_fuzzer_proto_http | High-throughput plaintext HTTP/1 and h2c upgrade negotiation. Retains cheap request options and raw response parsing; removes MIME, uploads, follow-on sockets, prior-knowledge HTTP/2, HTTP/3 selection, and timing controls. |
curl_fuzzer_proto_http_deep | Stateful HTTP coverage, including redirects, dead connection-pool pruning, authentication, MIME construction and redirected body rewinds, uploads, result APIs, bounded cookie, Alt-Svc, HSTS, and netrc files; harness-owned forward-proxy, HTTP CONNECT (including a bounded pending HAProxy-prefix pollset profile), and abstract Unix-domain transports; plus fixed no-proxy/environment-proxy parsing and owned-loopback interface/keepalive setup. |
curl_fuzzer_proto_http2 | High-throughput HTTP/2 origin frames over plaintext prior knowledge, using the same structured and malformed frame grammar as the TLS lane. |
curl_fuzzer_proto_https | HTTP/1.1 through a real in-process TLS peer, including certificate, session, TLS result-state, bounded CRL and public-key-pin files, closed harness-owned client-credential profiles, and fixed OpenSSL close-notify and HelloRequest probes. |
curl_fuzzer_proto_https_gnutls / curl_fuzzer_proto_https_mbedtls | The HTTPS profile with a GnuTLS or mbedTLS curl client; the local server side remains the harness TLS peer and both variants reuse the HTTPS generated seed corpus. |
curl_fuzzer_proto_https_h2 | Structured and malformed HTTP/2 origin frames after a verified TLS/ALPN handshake, input-selectable X25519 or provider-default key exchange, plus push, live stream-weight, fatal-session, and upkeep probes. |
curl_fuzzer_proto_http3 | Structured or raw HTTP/3/QPACK work after a real local QUIC/TLS handshake, or capsule traffic through a local HTTP/1.1 CONNECT-UDP proxy. |
curl_fuzzer_proto_h2_proxy | An HTTP/1.1 origin request through a fixed trust-anchor-verified HTTPS CONNECT proxy. connection.proxy_protocol selects the existing HTTP/2 frame peer or a bounded HTTP/1.1 CONNECT response profile; proxy authentication options remain owned by this lane. |
curl_fuzzer_proto_socks4 | HTTP through an in-process SOCKS4, SOCKS4A, SOCKS5, or SOCKS5-hostname proxy, including scripted SOCKS5 username/password authentication and an optional fixed HAProxy protocol-v1 prefix. The historical basename is retained as the target identity. |
curl_fuzzer_proto_resolver | Localhost and bounded CURLOPT_RESOLVE cache operations; deterministic positive, negative, link-local retry, queued-cancellation/idle-wait, and Unix-socket resolver states; a real loopback happy-eyeballs race; and loopback HTTP/HTTPS DoH normal, malformed-DNS, HTTP-error, or transport-error responses. |
curl_fuzzer_proto_ws | Plaintext WebSocket handshake, framing, callbacks, and manual-drive paths. |
curl_fuzzer_proto_wss | Verified in-process TLS followed by the WebSocket handshake, framing, callbacks, and manual-drive paths. |
curl_fuzzer_proto_telnet | Bounded TELNET negotiation and callback-backed input against a preloaded local peer. |
curl_fuzzer_proto_ftp | Plain FTP control plus passive or loopback-confined active data connections. The same target runs implicit-TLS ftps:// scenarios through a verified in-process TLS peer, with both control and passive data channels wrapped and active mode deliberately unavailable. |
curl_fuzzer_proto_tftp | Packet-preserving TFTP exchanges over private loopback UDP endpoints. |
curl_fuzzer_proto_smtp | Single-connection SMTP greeting, EHLO/HELO, mail envelope, DATA body upload and EOB drain, graceful QUIT, and opt-in active pollset probes. The same target also runs implicit-TLS smtps:// scenarios through a verified in-process TLS peer. |
curl_fuzzer_proto_imap | Single-connection IMAP greeting, CAPABILITY, LOGIN/AUTHENTICATE (including SASL LOGIN, DIGEST-MD5 challenge handling, and XOAUTH2 bearer construction), SELECT, SEARCH, FETCH literals and custom listings, APPEND uploads, tagged completion, and opt-in active pollset probes. The same target also runs implicit-TLS imaps:// scenarios through a verified in-process TLS peer. |
curl_fuzzer_proto_pop3 | Single-connection POP3 greeting, CAPA, USER/PASS or APOP, LIST, RETR multi-line bodies, recv-buffer pressure, semantic STLS filter-allocation failure, QUIT, and opt-in active pollset probes. The same target also runs implicit-TLS pop3s:// scenarios through a verified in-process TLS peer. |
curl_fuzzer_proto_mqtt | Single-connection binary MQTT CONNECT/CONNACK, PUBLISH or SUBSCRIBE/SUBACK, server-initiated PUBLISH packets, DISCONNECT, and closed queued-send and oversized-credential probes. The same target also runs implicit-TLS mqtts:// scenarios through a verified in-process TLS peer. |
curl_fuzzer_proto_dict | Single-connection DICT lookups. curl writes CLIENT, one URL-derived command, and QUIT together, then reads the multi-line text response until the peer half-closes. |
curl_fuzzer_proto_gopher | Bounded Gopher or Gophers selectors through a stream peer, including closed partial-write, send-timeout, and TLS session-key size probes. |
curl_fuzzer_proto_file | Peerless local-file reads, ranges, resumes, metadata, and bounded uploads through a harness-owned anonymous regular file. The runtime constructs the complete file:///proc/self/fd/N URL and never accepts a protobuf-controlled pathname. |
curl_fuzzer_proto_ldap | Single-connection BER/ASN.1 LDAP simple or SASL PLAIN bind and search through OpenLDAP, including bounded root-DSE mechanism discovery, a scripted bind reply, scripted search-result entries, and synthesized search completion. ldap_starttls drives a correlated plaintext ExtendedRequest/ExtendedResponse before curl installs its TLS filter; SCHEME_LDAPS runs the ordinary exchange over implicit TLS against the in-process OpenSSL peer. |
curl_fuzzer_proto_rtsp | Request-aligned RTSP method/CSeq exchanges, SDP response bodies, PLAY responses with interleaved RTP packets, and closed cached-connection/Session and split-invalid-RTP write-failure probes. |
curl_fuzzer_proto_ipfs | ipfs:// and ipns:// gateway rewrites as ordinary HTTP GETs against the in-process HTTP peer, with a pinned CID/name and a mutation-controlled gateway path. |
curl_fuzzer_proto_api | Easy, share, multi, URL, connect-only, pause/resume, callback-MIME same-handle reuse, and typed result API lifecycles described by api_plan. |
curl_fuzzer_proto_multi | Two to four easy handles on one shared multi handle, with bounded scheduling actions, connection-cache controls, an opt-in closed progress/rate-limit probe, and a five-second packaged timeout. |
curl_fuzzer_proto_timing | Plain HTTP or WebSocket backpressure and timed-wait behavior; it guarantees a non-default bounded pressure configuration. |
The exact target inventory and platform gates are maintained in
scripts/fuzz_targets. The base structured targets are included in OSS-Fuzz’s
AddressSanitizer/i386 build. The GnuTLS, mbedTLS, and HTTP/3 variants are
excluded from i386 and MemorySanitizer builds, and HTTP/3 is created only when
its dependency variant is enabled.
MemorySanitizer omits OpenSSL and the TLS mock peer. The plaintext
curl_fuzzer_proto_http2 lane remains active in that build. In contrast,
curl_fuzzer_proto_https_h2 and curl_fuzzer_proto_h2_proxy return without
driving their peer-dependent scenarios, while curl_fuzzer_proto_https cannot
complete its ordinary verified-TLS path.
What policy application changes
Before a fixed target executes a loaded or newly mutated generated message, its postprocessor lowers it into the internal runtime representation and:
- fixes or narrows the scheme and, where required, canonicalizes the authority;
- evaluates generated conditional eligibility after selector normalization;
- removes variant-specific options and fields that the selected mode cannot consume;
- caps options, headers, response chunks, connection count, upload data, MIME parts, API actions, and protocol-specific byte budgets; and
- canonicalizes small enums and transport settings onto useful, safe ranges.
Static option and field selection is declared beside fields in
schemas/curl_fuzzer.proto and compiled into each generated profile root.
Conditional membership is also declared there and emitted as a generated
pruner. The shared limits and semantic selector normalization remain in
proto_fuzzer/scenario_limits.h and proto_fuzzer/target_policy.cc. Runtime
code repeats important bounds so callers that bypass mutation policy cannot
create unchecked work.
The packaged libFuzzer configuration also caps each serialized structured
input at 32 KiB.
Fast lanes normally clear backpressure because a single mutated value would
otherwise move an ordinary case into a timed loop. IMAP and POP3 retain only a
bounded receive-buffer size and clear drain throttling, so long commands can
short-write without adding waits. The timing lane retains both controls and
forces bounded pressure. Direct HTTP/3 discards the stream Connection;
CONNECT-UDP mode retains it and clears direct HTTP/3 actions. FTP, TFTP,
SMTP, IMAP, POP3, MQTT, DICT, LDAP, LDAPS, FTPS, MQTTS, IMAPS, POP3S, RTSP,
IPFS/IPNS, TELNET, Gopher, and FILE prune fields that their specialized peers
(or, for FILE, the peerless local handler) cannot observe. The IPFS/IPNS lane
rewrites both schemes to a /ipfs/<cid> or /ipns/<name> HTTP path on a fixed
loopback gateway before the request reaches the ordinary HTTP peer. The FILE
lane additionally removes host_path from its generated schema and exposes
bounded file_source bytes through a session-owned anonymous descriptor. It
retains bounded upload state and can read or write only that descriptor; the
runtime constructs the complete file:///proc/self/fd/N URL without granting
access to host files.
The resolver profile also clears arbitrary backpressure. Its successful
HTTPS-RR exchange has a trace-enabled coverage twin with the same peer
transcript; trace state remains scoped to that input. A second HTTPS-RR seed
requests ECH and defers that answer for one drive turn, so TLS setup observes
the record as pending before the transfer completes. Its five threaded
resolver_mode values install one fixed timed-drive marker instead. Their
authorities are pinned to names handled by the resolver binary’s target-local
getaddrinfo backend: no ambient name service is consulted, returned address
lists are owned and bounded, and a fixed family delay makes the partial-result
path intentional. The queued-cancellation mode holds one worker-owned lookup,
cancels only later queue-owned work, then requires the public THREADS trace
to report the worker idle after release; acquiring that trace state proves the
worker entered its configured timed wait. The Unix mode gives curl a fixed
nonexistent pathname, but the open-socket callback still substitutes its
anonymous socketpair. The DoH settings retain the historical HTTP listener or
select a trust-anchor-verified HTTPS listener, independently of normal
HTTPS-RR answers, one-byte malformed DNS messages, a non-success HTTP status,
or a close after a complete request. Three closed HTTPS option probes own a
custom diagnostic stream, bounded TLS pathnames, or SSL_CTX callback state
long enough for curl-created DoH handles to inherit them. A dedicated
localhost seed balances the process-wide curl cleanup and
allocator-preserving reinitialization lifecycle between inputs, after all
per-transfer resolver workers have been joined.
The fixed SMTP, IMAP, POP3, FTP/FTPS, MQTT/MQTTS, and proxy-transport profiles
retain probe_multi_pollset; deep HTTP retains it only for its CONNECT proxy
mode. Every other fixed profile clears it. The flag adds zero-timeout public
multi-poll calls only while those peers’ perform loop has a live transfer and
protocol socket, so ordinary false/default corpus entries do not pay for an
unconditional polling syscall. The proxy-transport lane may probe several
bounded perform turns because its HAProxy filter is installed only after SOCKS
negotiation completes.
The multi profile’s MULTI_STATE_PROBE_PROGRESS_RATE_LIMIT value is a closed
state-machine probe. The runtime redirects the internal progress meter to an
owned anonymous temporary stream, enables it, and applies a fixed one-byte per
second receive limit plus a 20-millisecond transfer timeout to every easy
handle. The authored seed releases each two-byte response body over two peer
turns so curl must observe the rate-limited state before cleanup. None of the
sink, rate, or timeout values is mutation-controlled, and failure to create the
anonymous stream leaves the probe disabled.
This probe deliberately does not manufacture the error-only
Curl_creader_free path: that function needs a request-body reader to be
created and then rejected, which would couple this generic state-machine case
to an unrelated allocation-failure index. It also does not claim
Curl_multi_wakeup_internal, whose live call site belongs to the threaded
resolver lifecycle rather than the generic multi runner. The identity content
decoder used by the seed does safely exercise Curl_cwriter_def_write as part
of the response path.
The compatibility profile is intentionally broad. Its generated root preserves empty option slots and its runner keeps the mixed execution mode rather than applying one fixed lane’s restrictions. The typed-option cutover was still a breaking in-place format change: pre-cutover OSS-Fuzz corpus and crash files must be replayed with their original revision and target. New fields and enum values must continue to avoid reinterpreting older wire tags.
Choosing a target for a seed or crash
Use the narrowest target that owns the behavior:
- Prefer
httpfor cheap request/response parsing,http_deepfor stateful or file-backed work, andtimingfor intentional backpressure. - Use
https,http2,https_h2, orhttp3according to the actual transport rather than putting protocol frame setup into an HTTP seed. - Put lifecycle-only work in
apiormulti, keeping it out of protocol-hot loops. - Replay a crash with the binary and curl-fuzzer revision named by OSS-Fuzz. Profile roots preserve common field tags, but different profiles can discard or normalize the same bytes differently, and pre-cutover bytes use a different option layout.
See Writing and inspecting scenarios for corpus generation and Extending the structured suite for the invariants a new lane must implement.
Extending the structured suite
Structured inputs work best when mutations reliably reach curl without making each iteration expensive. Extend the smallest existing surface that owns the behavior. Consider serialization compatibility, object lifetimes, target policy, and local transport isolation together.
Add a supported curl option
SetOption is for scalar values and copied or scenario-owned strings. The
complete checklist for an ordinary addition is:
- Confirm that the option fits this path. Callbacks, slists, blobs, filenames, and other pointer-bearing state usually need a schema-native field and explicit runtime ownership instead.
- Add one field to
SetOption.settinginschemas/curl_fuzzer.proto. Assign a new, never-reused tag and usebool,uint64, orbytesaccording to the value curl consumes. - Add the field’s
(curl.fuzzer.schema.curl_option)annotation with the exact nativeCURLOPT_*name and its complete profile eligibility. Use a closed conditional guard only when normalized selector state really changes whether the option is meaningful. - Add or update a target-relevant textproto seed when useful reachability requires correlated options, request data, or peer bytes.
- Run the schema tests, inspect the affected generated profile report, build that target, decode its generated seed, and replay it. Add runtime tests if the option needs value normalization, new selector semantics, ownership, or peer behavior.
The schema generator checks the native name and declared protobuf type against
the selected checkout’s curl.h. From that one field it derives the internal
CurlOptionId, value-kind/native manifest, lookup switch, allowed profile
cases, and direct lower/lift transfers.
Do not add a handwritten C++ option ID, kind override, profile allowlist, or
adapter case. If none of the existing closed guard facts describes a new
conditional rule, add and test a new fact deliberately; do not encode an
arbitrary expression in the schema.
Callbacks, slists, files, and other pointer-bearing options usually need a schema-native field plus explicit harness-owned storage. Their backing objects must remain alive through transfer completion and easy-handle cleanup. Existing examples include request headers, MIME, upload state, TELNET options, resolver entries, and bounded anonymous parser files.
Keep filename-backed cookie, Alt-Svc, and HSTS options out of SetOption; the
baseline owns their fixed /dev/null sinks, while the typed option oneof
exposes only their in-memory controls. CURLOPT_FTPPORT and
CURLOPT_FTP_USE_EPRT are safe
because the plaintext FTP peer confines active listeners to loopback; the
implicit-TLS FTPS lane removes them and supports passive data channels only.
The slist-backed QUOTE options still need protocol-specific ownership before
they can join this surface. The TFTP controls reuse the schema’s upload
body and size fields to exercise option negotiation and raw RRQ/WRQ creation.
Some scalar options still need correlated seeds. The HTTP Message Signature key, key ID, header list, and algorithm options are one example: keep their values together in the deep HTTP corpus so mutations can reach both parsing and rejection paths.
Add or change a schema field
The binary corpus is persistent. Add new fields with unused field numbers and append new enum values; never renumber or reuse an existing tag. Choose a default that preserves the behavior of older serialized inputs.
Then update all parts of the contract:
- Add complete schema-owned eligibility, including contextual-path metadata when applicable. If a closed guard fact reads the new field, add its path to that fact’s generator-owned input list; schema generation verifies that every guarded profile retains it through selector normalization.
- Add an appropriate complexity limit in
proto_fuzzer/scenario_limits.hand enforce the runtime-visible bound inproto_fuzzer/target_policy.cc. - Implement ownership and execution in the request-data, runner, or peer layer.
- Decide deliberately whether the generated compatibility root should expose the field; a newly assigned tag must not accidentally reinterpret historical bytes.
- Add policy tests for retention, removal, canonicalization, and boundary values, plus runtime tests when the field owns resources or changes I/O.
Protobuf repeated and bytes fields have no intrinsic work bound. In fixed
lanes, a runtime that merely ignores an oversized suffix is not sufficient.
The profile projection must exclude ineligible shapes, and target normalization
must prune conditional fields before applying bounds so libprotobuf-mutator
does not keep allocating and mutating data that cannot add coverage. Runtime
bounds remain necessary for direct callers and for the broad compatibility
lane.
Add a target profile
A new lane normally requires all of the following:
- A file-level profile declaration in
schemas/curl_fuzzer.proto, including its exact C++TargetProfilename, generated root, and empty-option policy; add complete membership to every field and option it can reach. - A
TargetProfileand completeRunModeFormapping inproto_fuzzer/target_profile.h. Schema generation checks that this C++ enum and the registry are complete and unique. - A policy branch in
proto_fuzzer/target_policy.ccthat fixes routing, normalizes selectors, and applies semantic bounds. Static field and option membership belongs in the schema, not this branch. - A bounded peer or runner path. It must not consult ambient DNS, proxies, trust stores, or remote endpoints, and malformed scripts must terminate by operation or idle budgets rather than long wall-clock waits.
- A thin
fuzzer_entrypoints/curl_fuzzer_proto_<name>.ccbinding its generated Traits once into akProfiledescriptor and passing that descriptor to all three ordinary libFuzzer entrypoint helpers. - A CMake executable and profile-generation declaration, generated corpus
declaration (or an explicit reuse mapping in
scripts/fuzz_corpus_helpers.sh), and dependency from the executable to that corpus. - An
ossconfig/<target>.optionsfile and an entry inscripts/fuzz_targets, with architecture or sanitizer gates when required. - Schema-generation, entrypoint, target-policy, peer/runtime, packaging, and representative scenario tests.
- A row in Target profiles.
Keep throughput-sensitive parsing separate from expensive lifecycle, TLS, file
parser, or timed work. Existing http, http_deep, and timing profiles show
how one protocol surface can be split without making its fast lane pay for
every coverage feature.
Verify a change
Keep function descriptions, parameter contracts, and return documentation beside
their definitions in .cc files. Do not repeat them on header declarations.
Class, type, and field documentation stays in headers, as does documentation for
functions defined there and pure virtual contracts. Run the placement and
coverage checks with cmake --build build --target doxygen-check.
Run the focused Python tests for schema generation and target wiring, then build the affected binary and the C++ unit-test aggregate:
uv run --extra python-tests pytest \
tests/test_schema_eligibility.py \
tests/test_active_schema_cutover.py \
tests/test_fuzzer_entrypoints.py
./mainline.sh -t curl_fuzzer_proto_http
cmake --build build --target fuzzer_unit_tests
ctest --test-dir build --output-on-failure
Replace the example target with the lane being changed. Finally, decode and replay each new generated seed as described in Writing and inspecting scenarios. For performance-sensitive policy changes, use the benchmarking guide; source coverage and executions per second answer different questions and should be checked together.
C++ reference
The structured harness C++ reference documents
the classes and functions under proto_fuzzer/ for contributors. It includes
ownership and lifetime contracts, searchable symbols, and links to implementation
source. Generated protobuf types and dependency internals are outside its scope.
Doxygen renders the reference directly from the source. Function descriptions
live beside their definitions in .cc files; headers retain type and member
documentation. See Development to build it
locally.
Python tools
The curl-fuzzer-tools package requires Python 3.10 or newer.
Install with uv
uv sync
uv run read_proto_corpus /path/to/testcase
Add the Python test extra when developing:
uv sync --extra python-tests
Install with pip
python3 -m venv .venv
. .venv/bin/activate
python -m pip install -e .
Commands
| Command | Purpose |
|---|---|
read_proto_corpus | Decode one binary protobuf scenario using protoc |
generate_decoder_html | Build the standalone browser decoder for protobuf Scenario inputs |
generate_matrix | Package built fuzzers into balanced artifact shards and produce the CI matrix |
prepare_fuzzer | Extract one fuzzer and its supporting files from a CI artifact shard |
generate_schema_eligibility | Validate annotated schema eligibility and emit authoring, runtime, profile, adapter, report, and machine-contract artifacts |
compare_fuzzers | Compare repeatable throughput and source coverage measurements between fuzzer builds |
profile_fuzzer | Collect a reproducible Linux perf profile from one production libFuzzer target |
bootstrap_http2_corpus | Select compatible HTTP/2 inputs into a dedicated corpus |
check_python_protobuf | Verify that the installed Python protobuf runtime is compatible |
coverage_history | Fetch aggregate or per-target OSS-Fuzz curl/lib history as a table, JSON, CSV, or SVG |
Run any command with --help for its complete interface.
OSS-Fuzz coverage history
coverage_history reads the public daily summary.json artifacts and sums
the files under curl/lib. Aggregate reports use
https://storage.googleapis.com/oss-fuzz-coverage/curl/reports/{YYYYMMDD}/linux/summary.json;
passing --target selects
https://storage.googleapis.com/oss-fuzz-coverage/curl/reports-by-target/{YYYYMMDD}/{TARGET}/linux/summary.json.
Each JSON or CSV observation retains the exact URL used.
uv run coverage_history \
--start 2026-10-01 --end 2026-10-28 \
--format svg --metric functions > curl-lib-functions.svg
uv run coverage_history \
--start 2026-10-01 --end 2026-10-28 \
--target curl_fuzzer_proto_ldap --format json
Schema generation and seed encoding
CMake invokes generate_schema_eligibility after host protoc has compiled
the annotated schema to a descriptor set. The command validates the profile
registry, contextual field shapes, native option names and types, and the
mapping to the C++ TargetProfile enum. Its generated files live under
build/schema_eligibility/; the metadata-free full authoring schema is staged
separately at build/schemas/curl_fuzzer.proto.
Normal checked-in textprotos need no projection command. CMake encodes the full
authoring Scenario with ordinary protoc --encode, and each restricted target
root preserves retained field tags while discarding unknown fields. Generated
conditional pruning and C++ target normalization run after the target parses
those bytes.
The typed-option edit was a breaking in-place schema change. Historical binary inputs are not detected or converted by current tooling; reproduce them with the curl-fuzzer revision and target that produced them.
Browser decoder
The published decoder accepts protobuf
Scenario inputs and does not upload the selected file. The generator bundles
the locked protobuf.js runtime and a metadata-free full authoring schema into
the HTML, so the resulting page does not need the generator-only eligibility
import and does not load a decoder from a CDN. It reuses
build/schemas/curl_fuzzer.proto when available; otherwise it invokes protoc
on the annotated checked-in schema and strips the metadata in memory. Build the
complete documentation site and decoder locally with:
npm ci --ignore-scripts --no-audit --no-fund
mdbook-mermaid install .
mdbook build
uv run generate_decoder_html --output _site/corpus-decoder/index.html
Then open _site/index.html for the book or
_site/corpus-decoder/index.html for the decoder. Build mdBook first because it
recreates the output directory.
Coverage
codecoverage.sh builds curl and the fuzzers with LLVM source-based coverage,
replays the available corpora, and reports libcurl and curl-fuzzer separately.
The libcurl report includes curl’s lib/, public include/curl/ headers, and
any mapped src/ files. The harness report covers checked-in C++ sources and
headers, excluding tests, generated protobuf code, dependency build trees, and
the standalone replay engine.
./codecoverage.sh
The generated reports under build-coverage/coverage/ are:
summary.txtandhtml/index.html, libcurl text and annotated HTML reports;harness-summary.txtandharness-html/index.html, harness text and annotated HTML reports;curl-coverage.jsonandharness-coverage.json, scoped LLVM exports for further analysis;report.mdandgaps.json, separate line, function, region, and branch counts plus ranked remaining gaps;replay-status.json, per-target input counts, exit codes, runner counters, and fresh profile evidence;replay-logs/andreplay-counters/, retained target diagnostics and numeric input outcomes.
Percentages use exact covered/total counters. An unavailable metric or empty
denominator is not reported as 100%. Counts cover sources mapped into the
selected binaries; disabled features and other platforms require separate
builds. Compare changes using the same curl revision, toolchain, features, and
corpora, and investigate failed or incomplete replay before interpreting a
result as complete. report.md labels the selected replay as complete or
partial before presenting coverage percentages. A complete replay requires
every selected target to finish every counted input without detected errors
and to write a fresh nonempty profile; it does not mean 100% source coverage.
Failed, missing, or incomplete targets remain explicit while later targets
continue to contribute partial coverage.
Coverage and Fuzz Introspector builds configure curl with
CURL_DISABLE_TYPECHECK=ON. curl’s public C type-checking macros provide
compile-time diagnostics, but internal curl_easy_setopt calls can expand
them into thousands of regions whose type-error arms cannot execute. Removing
that compiler-only surface makes region coverage describe runtime behavior.
Runtime error strings and verbose tracing remain enabled and fuzzable. The
same setting is applied to local coverage (ENABLE_COVERAGE=ON or
SANITIZER=coverage) and Fuzz Introspector (SANITIZER=introspector) builds.
Measure a local curl checkout with:
./codecoverage.sh -c /path/to/curl
If ossfuzz_corpus/ exists, its downloaded inputs are replayed alongside the
checked-in or generated seeds. To rerun one target after an instrumented build:
BUILD_DIR="$PWD/build-coverage" \
TARGETS=curl_fuzzer_proto_http \
./scripts/run_coverage.sh
Each invocation replaces the previous replay profiles and derived reports, so a single-target rerun reports only that selection. Failed merges retain replay diagnostics without leaving old coverage reports available.
The GitHub Actions Coverage workflow is manual. It uploads both source reports
and their gap summaries and replay diagnostics, writes report.md to the job
summary, and reuses a public-corpus cache whose key changes each ISO week.
For controlled A/B performance and source-coverage comparisons, see Benchmarking.
curl_fuzzer_proto_api supplements corpus replay with deterministic public
compatibility API probes, such as formatting and deprecated form serialization.
The probes execute once per process in coverage builds (ENABLE_COVERAGE=ON
or SANITIZER=coverage) and Fuzz Introspector builds
(SANITIZER=introspector). They reuse the proto runtime’s global curl
initialization and never perform global cleanup from an input callback. The
runtime releases its global resolver and TLS state only during process exit.
An initialization hook covers empty-corpus merge jobs, while the first input
callback is a fallback for standalone replay engines that omit that hook.
Utility probes cover the version string, documented date formats, and deprecated escape/unescape aliases with short fixed strings and explicit binary lengths.
Dynamic formatting probes use curl’s real private buffer type and small explicit limits to exercise exact fits, staging boundaries, and size-limit errors. They allocate at most 1 KiB per case rather than depending on allocation failures or large formatted output.
Every structured target supports allocation fault injection through the
scenario’s fail_allocations field. The shared runtime installs libcurl’s
curl_global_init_mem
callbacks once, before its ordinary global initialization, then arms them only
for the shared scenario runner. Protobuf parsing remains outside the selected
sequence; libcurl easy-handle setup, transfer driving, result probes, and
teardown are all part of it except for narrowly scoped harness ownership
operations. The COPYPOSTFIELDS ownership commit and pushed-handle cleanup
enumeration suppress injected failures; allocations in those scopes neither
count nor consume ordinals, leaving failures pending for subsequent injectable
allocations. Result probes still honor their public state preconditions: in
particular, upkeep runs only after an easy drive has progressed far enough to
attach its private multi.
Values are one-based positive-size allocation attempt numbers. Fixed target
policies sort, deduplicate, and retain at most four ordinals in the range
1..4096; the compatibility runtime applies the same safety bound without
rewriting its persistent input. A plan such as 5, 9 rejects the fifth
attempt, then interprets 9 as the ninth attempt on the execution path already
changed by that first failure. Ordinals reproduce within the same target,
build, TLS backend, and input; they are not source locations or portable
identifiers.
The API target keeps its event-based easy drive for inputs without a failure
plan. When a plan is present, the target normalizes that drive to ordinary
curl_easy_perform: after its socket-monitor allocation fails, curl’s current
debug event adapter returns a status outside the socket-callback contract and
later removes a record it never inserted. This keeps the ordinal plan focused
on libcurl transfer and parser state without call-stack-dependent exclusions.
MIME bodies retain CURLOPT_CRLF and CURLOPT_TRANSFERTEXT under active plans,
so allocation injection covers failures while curl composes its MIME and
line-conversion readers. curl clears the partially installed reader stack if
that setup fails.
The active state is thread-local. Worker-thread allocations pass through, and only allocations routed through libcurl’s configured callbacks are counted. This includes most libcurl storage and some dependency allocations made through curl-provided adapters, but not general C++ harness, protobuf, or TLS library heaps. Free is never rejected, a rejected realloc preserves its input, and zero-size calls do not consume an ordinal. Each fuzzer entry resets the controller before parsing so the standalone runner’s timeout jump cannot carry an armed plan into the next input.
Reproducing fuzzer findings
OSS-Fuzz reports identify the target binary, fuzzing engine, sanitizer, and platform that found a failure. Reproduce with the closest available configuration: a crash found under UndefinedBehaviorSanitizer, for example, may not be visible in the default AddressSanitizer build.
These instructions assume that the testcase has been downloaded from the OSS-Fuzz report.
Inspect the testcase
Structured protobuf targets
The hosted corpus decoder decodes
protobuf Scenario files entirely in the browser without uploading them.
From a source checkout, read_proto_corpus uses the full authoring schema to
print field names. Current generated profile roots preserve its retained field
numbers and wire types, so the authoring view can inspect target payloads even
though libprotobuf-mutator uses a restricted target-specific type. The command
can also use the metadata-free staged copy under build/schemas/ when the
source schema is unavailable:
read_proto_corpus \
clusterfuzz-testcase-minimized-curl_fuzzer_proto_http-<id>
The command requires protoc. If it cannot find either schema, it falls back
to protoc --decode_raw; pass --proto-file to select one explicitly.
The typed-option cutover changed schemas/curl_fuzzer.proto in place. Always
pair a structured testcase with the curl-fuzzer revision and target that
produced it. A pre-cutover generic-option input can parse without error under
the current schema but mean something different; the runtime deliberately does
not auto-detect or fall back to the old format. Reproduce such an input at the
original revision and with the target that produced it.
Published reproduction images also include the metadata-free staged authoring
schema and a decode-scenario command:
docker run --rm -i \
-v "$PWD/clusterfuzz-testcase-minimized-curl_fuzzer_proto_multi-<id>:/testcase:ro" \
curlfuzzer.azurecr.io/address-libfuzzer \
decode-scenario /testcase
It also accepts the testcase on standard input:
docker run --rm -i curlfuzzer.azurecr.io/address-libfuzzer decode-scenario \
< clusterfuzz-testcase-minimized-curl_fuzzer_proto_multi-<id>
Direct parser targets consume target-specific raw bytes and generally have no separate decoder.
Reproduce with a local standalone build
Build the target, optionally against a local curl checkout:
./mainline.sh -t curl_fuzzer_proto_http
./mainline.sh -c /path/to/curl -t curl_fuzzer_proto_http
The resulting binary is under build/. Set FUZZ_VERBOSE to enable detailed
libcurl logging:
FUZZ_VERBOSE=1 ./build/curl_fuzzer_proto_http \
clusterfuzz-testcase-minimized-curl_fuzzer_proto_http-<id>
The default build uses AddressSanitizer. It is suitable for many
libfuzzer_asan findings and provides a fast edit-build-replay loop. A matching
OSS-Fuzz container is preferable when the report uses another sanitizer,
engine, architecture, or dependency configuration.
Reproduce in OSS-Fuzz
Follow the upstream OSS-Fuzz reproduction guide for complete instructions. From an OSS-Fuzz checkout, an UndefinedBehaviorSanitizer HTTP reproduction is:
python3 infra/helper.py build_image curl
python3 infra/helper.py build_fuzzers --sanitizer undefined curl
python3 infra/helper.py reproduce \
curl curl_fuzzer_proto_http \
/path/to/clusterfuzz-testcase-minimized-curl_fuzzer_proto_http-<id>
Once reproduced, use the normal sanitizer stack trace, debugger, and targeted
logging to narrow the fault. For AddressSanitizer investigations in GDB, a
breakpoint on __asan::ReportGenericError can stop at the point where the
runtime reports the invalid access.
Comparing fuzzer coverage and speed
compare_fuzzers runs repeatable A/B measurements against
production-style libFuzzer binaries. It records libFuzzer coverage (cov),
features (ft), executions per second, corpus growth, peak RSS, and wall/user/
system time. Complete logs and crash artifacts are retained, and any failed or
unparseable run makes the command fail.
The examples use uv run to invoke the entry point from the managed project
environment. Omit uv run after activating another environment where the
project is installed.
Build the baseline and candidate with the same curl revision, compiler, sanitizer, and OSS-Fuzz settings. For example, use two OSS-Fuzz output directories produced with the address sanitizer and libFuzzer. Then run:
uv run compare_fuzzers \
--baseline-dir /path/to/baseline/out/curl \
--candidate-dir /path/to/candidate/out/curl \
--seconds 30 \
--repeats 5 \
--cpu 0 \
--output build/measurements/comparison.json
The defaults cover the fixed structured fast HTTP, deep HTTP, HTTPS, WS, WSS,
TELNET, FTP, TFTP, API, and timing lanes. The deep HTTP target
retains stateful authentication, redirect, upload, MIME, and result-probe
work so the ordinary HTTP lane can concentrate its CPU budget on single-request
URL and response parsing. The original mixed-semantics
curl_fuzzer_proto target is retained for OSS-Fuzz corpus and testcase
compatibility, but is not benchmarked by default because its variable scheme
and timing policy obscures lane-level throughput.
Repeat --target to choose a different same-name A/B set:
uv run compare_fuzzers \
--baseline-dir /path/to/baseline/out/curl \
--candidate-dir /path/to/candidate/out/curl \
--target curl_fuzzer_proto_http \
--target curl_fuzzer_proto_http_deep \
--target curl_fuzzer_proto \
--target fuzz_url
To compare differently named targets, use a target pair:
uv run compare_fuzzers \
--baseline-dir /path/to/baseline/out/curl \
--candidate-dir /path/to/candidate/out/curl \
--target-pair curl_fuzzer_proto=curl_fuzzer_proto_http
The label in the report is, for example,
curl_fuzzer_proto=>curl_fuzzer_proto_http. Each side of a mapped pair receives
its own native corpus and seed archive. Same-name --target comparisons retain
the stricter behavior of sharing one content-identical snapshot. --corpus TARGET=PATH overrides are keyed by the actual binary target name, so a mapped
pair may supply one override for each side.
For a same-name target, the input snapshot combines the checked-in corpus with
the baseline’s <target>_seed_corpus.zip, deduplicating by SHA-256. A mapped
pair resolves the checked-in corpus and seed archive independently from its
baseline and candidate layouts. Add a downloaded fleet corpus with
--public-corpus-root ossfuzz_corpus. Override a target’s inputs completely
with one or more --corpus TARGET=PATH arguments; PATH may be a directory,
zip archive, or individual input.
Structured targets accept --public-corpus-root only when the root contains
.structured-schema-owned-eligibility-v1. Create that marker only after the
upstream structured corpora have been replaced and the local root has been
refreshed as described in Corpus management. Explicit
--corpus overrides remain the caller’s schema/revision assertion.
Proto corpus inheritance is limited to binaries that use the exact same generated profile, currently the GnuTLS and mbedTLS HTTPS variants reusing the OpenSSL HTTPS profile. The typed-option cutover does not treat compatibility or other profile corpora as interchangeable merely because retained protobuf tags can parse. Cross-profile corpora are not reused, and pre-cutover public corpora must be benchmarked with the revision and target that produced them.
Every run gets a fresh copy of one content-addressed snapshot. Repeat N uses
the deterministic seed --seed-base + N - 1; baseline/candidate order
alternates between repeats to reduce ordering bias. The JSON records corpus and
binary hashes, exact managed arguments, host details, individual results, and
medians.
Raw cov and ft values are instrumentation-specific coverage and feature
counts. They remain recorded for each run, but their cross-build deltas default
to null: matching target names, compiler versions, and curl revisions do not
prove that two binaries have the same coverage sites and feature semantics.
Pass --compare-libfuzzer-counters only when those are known to be identical.
Mapped targets always report these deltas as null. Use the source coverage
comparison below for coverage parity across different binaries.
Compare curl source coverage
Raw libFuzzer cov and ft counters include each harness and cannot prove
source coverage parity between different binaries. The comparison helper can
additionally replay the fixed native corpus through standalone LLVM coverage
builds and compare curl-relative function and code-region identities.
Create an instrumented build in each source worktree with codecoverage.sh.
The default build layout puts binaries under build-coverage/ and curl sources
under build-coverage/curl/src/curl_external, which the helper detects:
(cd /path/to/baseline-worktree && ./codecoverage.sh)
(cd /path/to/candidate-worktree && ./codecoverage.sh)
uv run compare_fuzzers \
--baseline-dir /path/to/baseline-libfuzzer-out \
--candidate-dir /path/to/candidate-libfuzzer-out \
--target-pair curl_fuzzer_proto=curl_fuzzer_proto_http \
--coverage-baseline-dir /path/to/baseline-worktree/build-coverage \
--coverage-candidate-dir /path/to/candidate-worktree/build-coverage \
--seconds 60 \
--repeats 5
The aggregate source-coverage comparison unions all selected target pairs. Benchmark each candidate lane separately when judging whether a change should shift its CPU allocation; supporting lanes may retain coverage without needing to match the primary lane’s throughput.
When curl was supplied with codecoverage.sh -c, pass the source roots
explicitly:
uv run compare_fuzzers \
... \
--baseline-curl-source-dir /path/to/baseline-curl \
--candidate-curl-source-dir /path/to/candidate-curl
Use --llvm-profdata and --llvm-cov for versioned executable names, and
--coverage-wall-timeout to change the per-target replay limit. The helper
validates all binaries and tools before fuzzing, requires byte-identical curl
lib/ and src/ source trees, and rejects a coverage binary that produces no
profile. This prevents line-number comparisons across different curl revisions
from looking meaningful.
The main JSON gains a source_coverage object with per-target and aggregate
summaries. Its comparison records baseline-retention percentages plus complete
baseline_only and candidate_only function/region lists. Raw profiles,
merged profiles, exports, and replay logs are preserved below
<log-dir>/source-coverage/. The source metric deliberately replays a fixed
input snapshot; discoveries from the timed fuzzing runs do not leak into it.
Timer and event-loop scheduling can still move an occasional region between
runs, so repeat the replay before treating a marginal one-region gap as real.
For a conventional combined HTML report, a single target can still be replayed after an instrumented build exists:
BUILD_DIR="$PWD/build-coverage" \
TARGETS=curl_fuzzer_proto_http \
scripts/run_coverage.sh
Use source coverage and libFuzzer features together when evaluating corpus minimization or harness changes: line coverage alone does not preserve value, counter, and indirect-call features used by the mutation scheduler.
Profiling fuzzer CPU use
profile_fuzzer records an on-CPU sampling profile for one
production libFuzzer binary. It is intended to find functions and call paths
that consume a significant part of the fuzzing budget. Use
compare_fuzzers afterwards to measure whether a
change actually improves executions per second.
The profiler requires Linux perf and Python 3.10 or newer. The fuzzer should
be an optimized, symbolized AddressSanitizer/libFuzzer build with frame pointers.
The default binaries produced by mainline.sh use the standalone replay engine
and are deliberately rejected.
Build a profiling binary
An OSS-Fuzz libFuzzer build supplies the same compiler, sanitizer, and runtime
shape used by the fleet. Given an OSS-Fuzz checkout at /path/to/oss-fuzz:
python3 /path/to/oss-fuzz/infra/helper.py build_fuzzers \
--sanitizer address \
--engine libfuzzer \
curl "$PWD"
The binaries, seed archives, dictionaries, and options are written beneath
/path/to/oss-fuzz/build/out/curl/. A target’s packaged .options file is part
of the workload: for example, the structured targets set max_len, and the
multi target sets a shorter per-input timeout.
Downloaded public corpora provide a more representative input population:
./scripts/download_public_corpus.sh
Record a profile
Profile one target for 60 seconds:
uv run profile_fuzzer \
--binary-dir /path/to/oss-fuzz/build/out/curl \
--target curl_fuzzer_proto_http \
--public-corpus-root ossfuzz_corpus \
--seconds 60 \
--output-dir build/profiles/proto-http
The profiler combines the checked-in corpus when a source checkout is
available, the target’s packaged seed ZIP, and compatible public corpora. It
deduplicates inputs by SHA-256 and runs with a fixed libFuzzer seed. Repeat
--corpus PATH to replace these default sources. Each path may be a directory,
ZIP archive, or individual input.
For structured targets, the public root must contain
.structured-schema-owned-eligibility-v1; see Corpus management.
An explicit --corpus path remains the caller’s schema/revision assertion.
By default, the profiler loads <target>.options beside the binary, falling
back to ossconfig/<target>.options when a source checkout is available. Use
--options-file to select another file and --fuzzer-arg=-name=value for
additional non-managed libFuzzer settings. The profiler owns duration, seed,
timeouts, artifacts, final stats, and single-process execution so the metadata
cannot disagree with the sampled workload.
The default event is the user-space software clock at 99 Hz. It works without a virtualized hardware performance counter and measures time actually executing on a CPU:
uv run profile_fuzzer ... \
--event cpu-clock:u \
--frequency 99
The default fp call graph requires frame pointers. Use --call-graph dwarf
for an existing binary that omitted them; DWARF stack samples are larger and
cost more to collect. --cpu N optionally pins the workload to one CPU.
The output directory must not already exist. A successful run contains:
perf.data, the original sampling data;perf-report.txt, a symbolized flat report with all sampled symbols;fuzzer.log, including the exact perf and libFuzzer diagnostics;metadata.json, with binary and corpus hashes, commands, effective options, host settings, execution metrics, and supplied provenance;corpus.zip, the exact deduplicated inputs present before fuzzing starts;summary.md, including the highest-overhead symbols; andartifacts/, for any testcase emitted by libFuzzer.
Pass corpus.zip back through --corpus to repeat the workload after its
original sources have changed or disappeared.
The text report remains useful after the build host is gone. Interactive use of
perf.data requires the exact profiled binary and its build ID; its SHA-256 and
original path are recorded in metadata.json. Preserve that binary alongside
the profile, then add it to perf’s build-ID cache before opening the data if the
original path is unavailable.
CI or build wrappers can attach revision information without exposing the complete environment:
uv run profile_fuzzer ... \
--provenance curl_revision=0123456789abcdef \
--provenance oss_fuzz_revision=fedcba9876543210
Run a profile in GitHub Actions
The Profile fuzzer workflow can be dispatched manually from the Actions
tab. Leave the target filter blank to create one matrix job for every target
offered by the workflow, or enter a case-sensitive substring such as http2 to
profile only matching targets. Then select a 30, 60, or 120 second duration.
Each job builds its target with OSS-Fuzz’s AddressSanitizer/libFuzzer
configuration, downloads the public corpus, and records a user-space
cpu-clock profile.
Each uploaded profile-... artifact contains the report, raw perf.data,
folded stacks in perf-folded.txt, an interactive flamegraph.svg, the log,
metadata, exact corpus.zip, build-toolchain manifest, and any testcase emitted
by the fuzzer. It also includes oss-fuzz-build.tar with the exact binary,
options, seed archive, and llvm-symbolizer needed to inspect the raw profile
after the hosted runner has gone away.
Interpreting results
Start with the widest stacks in flamegraph.svg and the highest-overhead symbols
in the flat report. The flame graph shows inclusive call paths, making it easier
to distinguish work owned by the harness from libcurl, TLS, parsing, compression,
protobuf mutation, and sanitizer checks. A hot function can be called frequently
because it is valuable coverage, so treat the profile as a place to investigate
rather than a reason to remove behavior.
Sampling overhead makes the run unsuitable as a throughput benchmark. Compare the candidate against an unchanged baseline with the same corpus and repeated runs as described in Benchmarking.
CPU samples deliberately exclude time spent asleep or blocked. Targets whose purpose includes timing, backpressure, or scheduler behavior need separate off-CPU analysis.
If perf record reports a permission error, inspect
/proc/sys/kernel/perf_event_paranoid and the host’s perf policy. The profiler
does not invoke sudo; changing that policy belongs to the local machine or CI
runner configuration.
Development
Run checks
The Python tests require the host protoc compiler on PATH and the
well-known .proto files (protobuf-compiler plus libprotobuf-dev on Debian
and Ubuntu). Install them, then install the Python testing and development
dependencies:
sudo apt-get install libprotobuf-dev protobuf-compiler
uv sync --extra python-tests
Run the Python tests and repository lint entrypoint:
uv run pytest tests/test_*.py
./lint.sh
mainline.sh builds the C++ tests and runs CTest for the normal AddressSanitizer
aggregate build. The MemorySanitizer lane compiles and links them but does not
execute against the host’s uninstrumented C++ runtime.
Documentation
Install the pinned mdBook and Mermaid preprocessor versions:
# renovate: datasource=crate depName=mdbook
MDBOOK_VERSION=0.5.4
# renovate: datasource=crate depName=mdbook-mermaid
MDBOOK_MERMAID_VERSION=0.17.1
cargo install mdbook --version "=${MDBOOK_VERSION}" --locked
cargo install mdbook-mermaid --version "=${MDBOOK_MERMAID_VERSION}" --locked
Install CMake and Doxygen for the C++ reference, plus
protoc for preparing the browser’s metadata-free authoring schema. With
Node.js 18 or newer, install the locked JavaScript dependencies used by the
protobuf corpus decoder:
npm ci --ignore-scripts --no-audit --no-fund
Then generate Mermaid’s JavaScript assets and build the documentation from the repository root:
mdbook-mermaid install .
mdbook build
cmake -B build .
cmake --build build --target doxygen-docs
mkdir -p _site/api
cp -R build/proto_fuzzer_reference/html _site/api/proto_fuzzer
uv run generate_decoder_html --output _site/corpus-decoder/index.html
The Mermaid assets are generated and ignored by Git. Delete them before
rerunning the installer after an mdbook-mermaid upgrade because the installer
does not overwrite existing assets. For live editing, run
mdbook serve --open. The browser decoder derives a metadata-free authoring
view from the annotated schema with protoc. Only when protoc is unavailable
does it reuse a staged build copy. The decoder and C++ reference are generated
separately and are therefore not refreshed by the mdBook development server.
Run their generation commands again after rebuilding the book. The
doxygen-docs target checks the source documentation before rendering HTML; it
does not compile curl or the fuzzers.
Run the optional browser tests for the guides, C++ reference, and decoder with:
uv sync --extra browser-tests
uv run playwright install chromium
uv run pytest tests/browser
The documentation CI builds the book, decoder, and C++ reference together. The
Pages workflow publishes _site/ from master for https://fuzz.curl.se/, with
the reference at /api/proto_fuzzer/. book.toml
records the intended hostname, but the custom domain and HTTPS enforcement must
also be configured in the repository’s Pages settings.
Generated files
Do not commit _site/, build/generated_corpora/, generated schema/profile
artifacts under build/schemas/ or build/schema_eligibility/, or downloaded
public corpora. Commit the annotated schemas under schemas/, authored
Markdown under docs/, direct-fuzzer seeds under corpora/, and full authoring
textproto seeds under scenarios/curl_fuzzer_proto/.