Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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, and telnet_options.
  • Peer work: connection, subsequent_connections, structured WebSocket frames, and http3_plan.
  • Focused lanes: api_plan, multi_plan and 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, and fail_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 101 handshake and accepts either raw chunks or structured server_frames. The WSS lane runs that same Upgrade, framing, callback, and manual-drive behavior after a verified in-process TLS handshake. For CURLOPT_CONNECT_ONLY=2, the peer coalesces the first bounded frame with the 101 response so libcurl must preserve already-read payload for curl_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_profile selects only harness-owned malformed blobs or fixed /dev/null engine/provider probes; protobuf bytes never become a filename, engine name, or provider name. pinned_public_key_file instead exposes bounded protobuf bytes through a session-owned anonymous descriptor and installs only its /proc/self/fd path, allowing curl’s filename-backed PEM pin parser to run without host-file access. The separate tls_handshake_profile can replace the OpenSSL peer with a fixed pre-handshake close_notify or 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 leave Curl_ssl_cfilter_add to a protocol peer capable of an in-place STARTTLS upgrade. Process-lifecycle probes for ossl_cleanup, Curl_ssl_cleanup, Curl_ssl_version, and Curl_tls_keylog_close belong 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_FTPS scenario 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’s PBSZ 0 and PROT P commands after login. Its fixed authority is tls.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_FTPPORT is removed from FTPS inputs. The plaintext-only ftp_auth_tls_filter_allocation_failure selector 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\n end marker before releasing the final 250. Envelope recipients come from the schema-native mail_rcpt list because CURLOPT_MAIL_RCPT is a curl_slist; the URL path supplies curl’s EHLO domain rather than the recipient in current libcurl. An SCHEME_SMTPS scenario runs the identical exchange over implicit TLS; its fixed authority is tls.test, matching the checked-in trust anchor, and the handshake carries no ALPN extension. Plaintext SMTP may select a closed smtp_probe_mode for 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 * OK greeting, each readable chunk answers the next command, and a status line beginning with OK, NO, BAD, or PREAUTH receives 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. An SCHEME_IMAPS scenario runs the identical exchange over implicit TLS; its fixed authority is tls.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/-ERR status lines need no rewriting. Multi-line CAPA, LIST, and RETR replies include their terminating dot line and are written unchanged. An SCHEME_POP3S scenario runs the identical exchange over implicit TLS against the same tls.test authority 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_protocol inserts 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_pollset field. When it is true, their drivers call zero-timeout curl_multi_poll after 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_POSTFIELDS supplies the publish payload. An SCHEME_MQTTS scenario runs the identical packet exchange over implicit TLS; its fixed authority is tls.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 CLIENT identification line, a command derived from the URL path (DEFINE from /d:word, MATCH from /match:word:db, SHOW from /show:db, or a raw command with colons replaced by spaces), and QUIT in a single send. It then reads the response as an unframed text stream, so the first non-CLIENT/non-QUIT on_readable entry is the complete body and the peer half-closes after writing it. Include the RFC 2229 .\r\n terminator in the seed when a well-formed body is desired; curl does not parse it and treats EOF as end-of-transfer. initial_response is normally empty because DICT has no server greeting.
  • FILE has no peer. Its generated schema omits host_path and retains bounded file_source bytes plus upload scripts and their scalar controls. The runtime places the source in a session-owned anonymous regular file and constructs the complete file:///proc/self/fd/N URL. 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-DSE supportedSASLMechanisms search, 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 consult CURLOPT_CUSTOMREQUEST. An SCHEME_LDAPS scenario runs the identical BER exchange over implicit TLS. In this curl revision OpenLDAP itself is built without TLS: lib/openldap.c drives 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 is tls.test, matching the checked-in trust anchor, and the handshake carries no ALPN extension. A plaintext SCHEME_LDAP scenario may instead set ldap_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_readable entry only after the terminating empty line, and drains any announced Content-Length body 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_REQUEST selects the method, and the response’s own CSeq stays mutation-controlled so mismatches reach curl’s CURLE_RTSP_CSEQ_ERROR path. 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 rewrites ipfs://<cid> and ipns://<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_path holds a pinned CID/name followed by the mutation-controlled gateway path, query, and fragment; the runner prepends the gateway authority.
  • The resolver lane’s doh settings bind a loopback DoH responder and point CURLOPT_DOH_URL at it. transport_mode preserves 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 carries alpn=h2,h3, so the separate outer HTTPS transfer completes through the in-process TLS/ALPN h2 peer using http2_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-default option_probe_mode values 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_RESOLVE is cleared because it cannot carry SVCB/HTTPS records. A paired coverage seed repeats that exact exchange with trace_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_mode values 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 local getaddrinfo backend. 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 public THREADS idle 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.sock to 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_protocol is 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 with h2_forward_proxy. The fast HTTP lane retains the HTTP/1.1 h2c Upgrade request and 101 transition instead.
  • Direct HTTP/3 uses ordered http3_plan actions after a valid QUIC/TLS handshake and discards the ordinary connection script. Setting http3_plan.use_h1_connect_udp_proxy instead uses connection for 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

  1. Put a descriptively named .textproto file in the closest protocol directory under scenarios/curl_fuzzer_proto/. Its filename stem must be unique across every source directory combined into the destination corpus, because generated corpora are flat.
  2. 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 a backpressure filename pattern.
  3. Build the destination target. CMake encodes the full authoring Scenario with ordinary protoc --encode and creates <name>.scenario in 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.
  4. 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.