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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ update: ## 📦 Update dependencies under the publish-age cooldown (UPDATE_ARGS=
cooldown-check: ## 🔎 Fail if a lockfile pins crates younger than the publish-age cooldown
@rustup toolchain install $(RESOLVER_TOOLCHAIN) --profile minimal > /dev/null && \
status=0; \
for manifest in Cargo.toml tooling/event-monitor/Cargo.toml; do \
for manifest in Cargo.toml tooling/event-monitor/Cargo.toml fuzz/Cargo.toml; do \
if ! out=$$(cargo +$(RESOLVER_TOOLCHAIN) update --dry-run -Z min-publish-age --manifest-path $$manifest 2>&1); then \
echo "WARNING: publish-age cooldown probe failed for $$manifest:"; echo "$$out" | grep -v "^ *Updating " | head -20; continue; \
fi; \
Expand Down Expand Up @@ -261,3 +261,21 @@ docs: ## 📚 Generate the documentation site under ./book

docs-serve: ## 📖 Serve the documentation locally with live reload
mdbook serve --open

# Fuzzing. The `fuzz/` crate is its own workspace on the nightly its
# rust-toolchain.toml pins; `cargo fuzz` is `cargo install cargo-fuzz`.
# See docs/fuzzing.md.
FUZZ_SECONDS ?= 60
FUZZ_ARGS ?= -timeout=10 -rss_limit_mb=2048

fuzz-list: ## 🎯 List the fuzz targets
cd fuzz && cargo fuzz list

fuzz: ## 🎯 Run one fuzz target until stopped (FUZZ_TARGET=<name>; FUZZ_ARGS go to libFuzzer)
@test -n "$(FUZZ_TARGET)" || { echo "FUZZ_TARGET is required, see 'make fuzz-list'"; exit 1; }
cd fuzz && cargo fuzz run $(FUZZ_TARGET) -- $(FUZZ_ARGS)

fuzz-smoke: ## 🎯 Run every fuzz target for FUZZ_SECONDS (default 60) each
cd fuzz && for target in $$(cargo fuzz list); do \
cargo fuzz run $$target -- -max_total_time=$(FUZZ_SECONDS) $(FUZZ_ARGS) || exit 1; \
done
5 changes: 5 additions & 0 deletions crates/net/p2p/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ repository.workspace = true
rust-version.workspace = true
version.workspace = true

[features]
# Exposes the crate-private gossip codec helpers to `fuzz/` (see
# `docs/fuzzing.md`). Never enabled by a production build.
fuzzing = []

[dependencies]
ethlambda-network-api.workspace = true
ethlambda-storage.workspace = true
Expand Down
24 changes: 24 additions & 0 deletions crates/net/p2p/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,30 @@ pub use req_resp::{request_beacon_block_by_root, request_beacon_blocks_by_range}
/// refuses a network whose value differs from this one.
pub use req_resp::MAX_PAYLOAD_SIZE;

/// The gossip payload codec and the lean topic parser, for the fuzz targets
/// in `fuzz/`: they are the first thing a peer's bytes reach, and nothing in
/// the production API needs them by name.
#[cfg(feature = "fuzzing")]
pub use gossipsub::{compress_message, decompress_message, topic_kind as lean_topic_kind};

/// The req/resp framing (varint length prefix, snappy frame, chunked
/// responses), for the same targets. These are what `Codec` runs on every
/// stream a peer opens.
#[cfg(feature = "fuzzing")]
pub use req_resp::Codec as ReqRespCodec;
#[cfg(feature = "fuzzing")]
pub use req_resp::encoding::{
ChunkLimits, DecodedPayload, decode_payload, decode_varint, encode_varint,
read_chunked_response, write_payload,
};

/// [`compute_message_id`] for the fuzz targets: a wrapper rather than a
/// re-export because the function itself stays private to the swarm builder.
#[cfg(feature = "fuzzing")]
pub fn gossip_message_id(message: &libp2p::gossipsub::Message) -> libp2p::gossipsub::MessageId {
compute_message_id(message)
}

// 5ms, 10ms, 20ms, 40ms, 80ms, 160ms, 320ms, 640ms, 1280ms, 2560ms
//
// This ladder, not a separate wall-clock budget, is what actually bounds how
Expand Down
1 change: 1 addition & 0 deletions docs/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,5 @@

- [Benchmarking](./benchmarking.md)
- [Era Replay](./era_replay.md)
- [Fuzzing](./fuzzing.md)
- [Spec Deviations](./spec_deviations.md)
116 changes: 116 additions & 0 deletions docs/fuzzing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
# Fuzzing

Coverage-guided fuzzing of the code a remote peer drives: every parser a
gossip message or a req/resp stream goes through, and the req/resp codec
that runs them, before the node decides anything about it. The targets live in `fuzz/`, a `cargo-fuzz` (libFuzzer)
crate at the repo root, and run on demand; there is no scheduled job yet.

## Running

Once:

```bash
cargo install cargo-fuzz --locked
```

`fuzz/rust-toolchain.toml` pins the nightly `cargo fuzz` needs (the same date
the dependency cooldown check installs), so nothing else changes toolchain.

```bash
make fuzz-list # the targets
make fuzz FUZZ_TARGET=beacon_gossip_decode # one target, until Ctrl-C
make fuzz-smoke # every target, 60 s each
make fuzz-smoke FUZZ_SECONDS=600 # ... 10 minutes each
```

`FUZZ_ARGS` goes to libFuzzer; the default is `-timeout=10 -rss_limit_mb=2048`,
because a decoder that needs ten seconds or two gigabytes on one input is a
finding, not a flake. Any libFuzzer flag can be added, for example
`FUZZ_ARGS="-timeout=10 -max_len=65536"` to grow inputs past the default 4 KiB.

A run that finds something stops and writes the input to
`fuzz/artifacts/<target>/crash-<hash>` (or `timeout-<hash>`,
`oom-<hash>`), and prints the panic and the stack. Reproduce it with

```bash
cd fuzz && cargo fuzz run <target> artifacts/<target>/crash-<hash>
```

and shrink it to the smallest input that still fails with

```bash
cd fuzz && cargo fuzz tmin <target> artifacts/<target>/crash-<hash>
```

The corpus a run grows is kept in `fuzz/corpus/<target>/` (git-ignored) and
reused by the next run of the same target, so coverage accumulates across
runs on one machine.

## What is fuzzed

Each target is one binary with one property, documented at the top of its
file in `fuzz/fuzz_targets/`. A target that covers a family of decoders
takes its first input byte as a selector, so libFuzzer mutates the choice
along with the payload (`ethlambda_fuzz::split`).

| Target | Entry point | Property |
|---|---|---|
| `snappy_raw` | `decompress_message`, `compress_message` | never panics, output under `MAX_PAYLOAD_SIZE`, compress then decompress is the identity |
| `topic_strings` | lean `topic_kind`; beacon `topics::*` | never panics on any string |
| `enr_parse` | `parse_enrs` | never panics, fed raw, `enr:`-prefixed and base64url-encoded so the RLP decoder is reached |
| `lean_gossip_ssz` | `SignedBlock`, `SignedAttestation`, `SignedAggregatedAttestation` | never panics; a decoded value re-encodes to bytes that decode |
| `lean_req_resp_ssz` | `lean::encoding::decode_request`, `decode_status_response` | never panics; an unknown protocol id decodes nothing |
| `beacon_gossip_decode` | `beacon::decode::decode_gossip` under `Config::mainnet()` | never panics; an unsubscribed kind is `UnknownTopic` |
| `beacon_slot_readers` | `block_slot`, `aggregate_slot`, `attester_slashing_slot` | never panic; agree with the full decode's slot |
| `beacon_status_metadata` | `beacon::encoding::decode_status`, `decode_metadata` | never panics for every protocol version; unknown id refused |
| `beacon_container_ssz` | `SignedBeaconBlock::from_ssz`, `DataColumnSidecar`, `Attestation` per fork; the gloas and sync committee decoders | never panics for every `ForkName::ALL` entry |
| `xmss_decode` | `ValidatorSignature`, `ValidatorPublicKey`, `MultiMessageAggregate` `from_bytes` | never panics (decode only, no verification) |
| `varint` | `decode_varint`, `encode_varint` | never panics; decode consumes input; every `u32` round-trips |
| `snappy_framed` | `decode_payload`, `write_payload` | never panics, output under `MAX_PAYLOAD_SIZE`, compressed size within what was read, write then decode is the identity |
| `chunked_response` | `read_chunked_response` under every `ChunkLimits` | never panics; context width as promised; never more chunks than the limit |
| `codec_lean` | `Codec::lean()` `read_request`/`read_response` per lean protocol | never panics; a beacon protocol is refused; what was read writes and reads back the same |
| `codec_beacon` | `Codec::beacon(..)` under `Config::mainnet()` per beacon protocol | as `codec_lean`, plus context bytes against the fork digest and per-protocol chunk limits |
| `message_id` | the gossipsub message id | always 20 bytes, deterministic, sensitive to the topic |

Generic SSZ decoding is not fuzzed here: `lambdaclass/libssz` runs its own
targets for that, differential against lighthouse's `ethereum_ssz`.

## Design

- **A separate workspace.** `fuzz/Cargo.toml` declares its own `[workspace]`,
so `libfuzzer-sys`, `arbitrary` and the nightly toolchain never enter the
production lockfile or build. Its lockfile is covered by
`make cooldown-check` like the other two.
- **`fuzzing` feature on `ethlambda-p2p`.** The gossip codec helpers, the
lean topic parser, the req/resp framing functions and a wrapper over the
message-id function are crate-private; the feature re-exports them for the
fuzz crate without widening the production API. The next layer (validation
rules with an in-memory store, discovery admission) will use the same
feature.
- **The codec over a cursor.** `Codec` is async over libp2p's `AsyncRead`, so
the targets drive it from `futures::io::Cursor` with `block_on`: the whole
inbound path as libp2p runs it, with no socket and no runtime. The fuzz
crate depends on the same libp2p fork and rev as the P2P crate, so the
`StreamProtocol` and `gossipsub::Message` it builds are the codec's own
types.
- **libssz pinned to the workspace's rev.** The fuzz crate names the same git
rev the root `Cargo.toml` does, so it cannot fuzz a different decoder than
the one the node ships.
- **Layering.** The first ten targets are layer 0, the wire parsers; the
six from `varint` on are layer 1, the framing and the codec as libp2p
drives them. The plan is bottom-up: next are the validation rules with an
in-memory store, then the live actor under a hostile peer. A bug in a lower
layer hides everything above it.

## When a target fails

1. Reproduce from the artifact (above) and minimise it with `tmin`.
2. Add the minimised bytes as a `#[test]` in the crate that owns the parser,
asserting the error it should return.
3. Fix the parser in its own PR, linking the artifact. The fuzz target stays.

## References

- cargo-fuzz and libFuzzer flags: <https://rust-fuzz.github.io/book/>
- libssz's fuzz crate, the layout this one copies: <https://github.com/lambdaclass/libssz/tree/main/fuzz>
- The beacon wire these parsers implement: [beacon_wire.md](./beacon_wire.md)
7 changes: 7 additions & 0 deletions fuzz/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
target/
# Crash and timeout inputs libFuzzer writes; a kept one becomes a regression
# test in the owning crate, not a file here.
artifacts/
# Grown by every run; seed inputs would go in `seeds/`, not here.
corpus/
coverage/
Loading
Loading