{
 "number": 36635,
 "repo": "bitcoin/bitcoin",
 "url": "https://github.com/bitcoin/bitcoin/pull/36635",
 "title": "fuzz: Add a target that feeds arbitrary bytes to V2Transport",
 "author": "fametrano",
 "author_association": "FIRST_TIME_CONTRIBUTOR",
 "created_at": "2026-10-23T23:46:10Z",
 "updated_at": "2026-11-01T18:51:27Z",
 "description_hash": "d05fb6528ed21c00",
 "age_days": 9,
 "draft": false,
 "labels": [
  "Fuzzing"
 ],
 "milestone": null,
 "base": "master",
 "head_sha": "984a01619e2f52b8ab9e4cec7a8ea2c2647724d7",
 "head_ref": "fuzz-v2-transport-arbitrary-bytes",
 "head_repo": "fametrano/bitcoin",
 "head_history": [
  {
   "t": "2026-11-01T18:47:23Z",
   "sha": "984a01619e2f52b8ab9e4cec7a8ea2c2647724d7"
  }
 ],
 "additions": 116,
 "deletions": 0,
 "changed_files": 1,
 "commit_count": 1,
 "size_bucket": "M",
 "mergeable_state": "clean",
 "bot": {
  "drahtbot": {
   "present": true,
   "reviews": {},
   "conflicts": []
  }
 },
 "acks_parsed": {},
 "acks_tally": {
  "ack": 0,
  "stale_ack": 0,
  "concept_ack": 0,
  "approach_ack": 0,
  "nack": 0,
  "concept_nack": 0,
  "approach_nack": 0
 },
 "reviews": {
  "approved": 0,
  "changes_requested": 0,
  "distinct_reviewers": [
   "uqlidi"
  ]
 },
 "signals": {
  "needs_rebase": false,
  "ci_failed": false,
  "mergeable_state": "clean",
  "last_author_activity": "2026-11-01T18:51:26Z",
  "last_reviewer_activity": "2026-10-31T20:36:08Z",
  "last_reviewer": "uqlidi",
  "author_silent_days": 0,
  "waiting_on_author_days": 0,
  "days_since_update": 0
 },
 "refs": {
  "mentioned": [],
  "depends_on": [],
  "fixes": [],
  "linked_issues": [],
  "references": [],
  "conflicts": []
 },
 "stack": {
  "shares_commits_with": [],
  "based_on": [],
  "base_for": []
 },
 "review_paths": [
  "src/test/fuzz/p2p_transport_serialization.cpp"
 ],
 "body": "No fuzz target feeds V2Transport bytes that an honest peer would not send past the v2 key exchange. I add p2p_transport_v2_receive. It knows the transport's keys, builds the input from arbitrary bytes and from parts of a valid handshake (the peer key, garbage, the terminator, encrypted packets that may be corrupted, and runs of empty decoys that force a rekey), and feeds it in pieces of arbitrary size. Once the transport reports v2, it checks that its session id matches the peer's.\n\nIt reaches the v2 receive states and their failures, except the fallback to v1 and the wrong-magic disconnect, which need a v1 `version` header at the start of the stream; `p2p_transport_bidirectional_v1v2` reaches the first. 15 minutes with libFuzzer, ASan and UBSan found no crash.",
 "commits": [
  {
   "sha": "984a01619e2f52b8ab9e4cec7a8ea2c2647724d7",
   "date": "2026-11-01T11:33:43Z",
   "message": "fuzz: Add a target that feeds arbitrary bytes to V2Transport\n\nNo fuzz target feeds V2Transport bytes that an honest peer would not\nsend past the v2 key exchange.\n\nAdd p2p_transport_v2_receive. It knows the keys of the transport it\ntests, and builds a byte stream from arbitrary bytes and from the parts\nof a valid handshake: the peer key, garbage, the garbage terminator,\nencrypted packets, which may be corrupted, and runs of empty decoys,\nwhich make the transport rekey. The stream is received in pieces of\narbitrary size.\nThis reaches the v2 receive states and their failures, except two\nthat need the 16-byte v1 prefix: the fallback to v1, which\np2p_transport_bidirectional_v1v2 reaches, and the disconnect of a v1\npeer with the wrong magic. Once the transport reports v2, its session\nid must match the peer's."
  }
 ],
 "timeline": [
  {
   "t": "2026-10-31T20:28:55Z",
   "kind": "review_comment",
   "who": "uqlidi",
   "assoc": "NONE",
   "path": "src/test/fuzz/p2p_transport_serialization.cpp",
   "commit": "0b0fa362fe68359f981348611ec050e290de79c1",
   "in_reply_to": null,
   "text": "The target calls `transport.GetInfo()` and drops the result.\n\nThe transport reports v2 once it verifies the version packet:\nhttps://github.com/bitcoin/bitcoin/blob/a773e1e33b349dc819b5a0edc1c7a4839dc73b48/src/net.cpp#L1595-L1600\n\nAt that point its session id should match the peer's. Would an assert on that work? I ran the check for 4 minutes and it didn't fail.\n\n```suggestion\n    const auto info{transport.GetInfo()};\n    if (info.transport_type == TransportProtocolType::V2) {\n        assert(info.session_id == uint256{MakeUCharSpan(peer.GetSessionID())});\n    }\n```",
   "id": "rc:4433189321",
   "url": "https://github.com/bitcoin/bitcoin/pull/36635#discussion_r4433189321",
   "hash": "0aad0e2d6a215241",
   "edited": "2026-10-31T20:40:23Z"
  },
  {
   "t": "2026-10-31T20:29:51Z",
   "kind": "review_comment",
   "who": "uqlidi",
   "assoc": "NONE",
   "path": "src/test/fuzz/p2p_transport_serialization.cpp",
   "commit": "0b0fa362fe68359f981348611ec050e290de79c1",
   "in_reply_to": null,
   "text": "```suggestion\n    const auto ent{ConsumeFixedLengthByteVector<std::byte>(provider, 32)};\n    const auto peer_ent{ConsumeFixedLengthByteVector<std::byte>(provider, 32)};\n```\n\nnit: I think `ConsumeFixedLengthByteVector<std::byte>(provider, 32)` does the same consume-then-resize and lets these be `const`:\nhttps://github.com/bitcoin/bitcoin/blob/a773e1e33b349dc819b5a0edc1c7a4839dc73b48/src/test/fuzz/util.h#L280\n\nIt should read the input the same way.",
   "id": "rc:4433191575",
   "url": "https://github.com/bitcoin/bitcoin/pull/36635#discussion_r4433191575",
   "hash": "a387eea378b3216d",
   "edited": "2026-10-31T20:40:23Z"
  },
  {
   "t": "2026-10-31T20:31:37Z",
   "kind": "review_comment",
   "who": "uqlidi",
   "assoc": "NONE",
   "path": "src/test/fuzz/p2p_transport_serialization.cpp",
   "commit": "0b0fa362fe68359f981348611ec050e290de79c1",
   "in_reply_to": null,
   "text": "nit: the `BIP324Cipher(key, ent)` constructor calls `key.EllSwiftCreate(ent)` and nothing else:\nhttps://github.com/bitcoin/bitcoin/blob/a773e1e33b349dc819b5a0edc1c7a4839dc73b48/src/bip324.cpp#L25-L29\n\nMaybe call that instead of building a second cipher? It gives the same public key.\n\n```suggestion\n    peer.Initialize(key.EllSwiftCreate(ent), /*initiator=*/!initiating);\n```",
   "id": "rc:4433195985",
   "url": "https://github.com/bitcoin/bitcoin/pull/36635#discussion_r4433195985",
   "hash": "897992aadb666fdc",
   "edited": "2026-10-31T20:40:23Z"
  },
  {
   "t": "2026-10-31T20:32:40Z",
   "kind": "review_comment",
   "who": "uqlidi",
   "assoc": "NONE",
   "path": "src/test/fuzz/p2p_transport_serialization.cpp",
   "commit": "0b0fa362fe68359f981348611ec050e290de79c1",
   "in_reply_to": null,
   "text": "nit: `stream` and `aad` hold `uint8_t`, but most bytes going in are `std::byte`. That forces casts both ways: `UCharCast` in `append_fn`, and `MakeByteSpan` / `MakeWritableByteSpan` for `Encrypt`.\n\nMaybe store `std::byte` and convert once with `MakeUCharSpan(stream)` before `ReceivedBytes()`? I think that also removes the duplicate insert in the \"Arbitrary bytes\" and \"Garbage\" branches, and it reads the input the same way.",
   "id": "rc:4433198711",
   "url": "https://github.com/bitcoin/bitcoin/pull/36635#discussion_r4433198711",
   "hash": "0b35a37f17dea9e5",
   "edited": "2026-10-31T20:40:23Z"
  },
  {
   "t": "2026-10-31T20:33:04Z",
   "kind": "review_comment",
   "who": "uqlidi",
   "assoc": "NONE",
   "path": "src/test/fuzz/p2p_transport_serialization.cpp",
   "commit": "0b0fa362fe68359f981348611ec050e290de79c1",
   "in_reply_to": 4433198711,
   "text": "diff:\n```diff\ndiff --git a/src/test/fuzz/p2p_transport_serialization.cpp b/src/test/fuzz/p2p_transport_serialization.cpp\nindex 852994afe..d6e0bd5c5 100644\n--- a/src/test/fuzz/p2p_transport_serialization.cpp\n+++ b/src/test/fuzz/p2p_transport_serialization.cpp\n@@ -429,34 +429,33 @@ FUZZ_TARGET(p2p_transport_v2_receive, .init = initialize_p2p_transport_serializa\n     peer.Initialize(BIP324Cipher{key, ent}.GetOurPubKey(), /*initiator=*/!initiating);\n\n     // Build the byte stream.\n-    std::vector<uint8_t> stream;\n+    std::vector<std::byte> stream;\n     // Garbage appended since the last key, which the first packet after the terminator must\n     // authenticate.\n-    std::vector<uint8_t> aad;\n+    std::vector<std::byte> aad;\n     auto append_fn = [&](std::span<const std::byte> bytes) {\n-        stream.insert(stream.end(), UCharCast(bytes.data()), UCharCast(bytes.data() + bytes.size()));\n+        stream.insert(stream.end(), bytes.begin(), bytes.end());\n     };\n     // Flip a bit in stream, at or after position start.\n     auto flip_fn = [&](size_t start) {\n-        stream[provider.ConsumeIntegralInRange<size_t>(start, stream.size() - 1)] ^= 1 << provider.ConsumeIntegralInRange<unsigned>(0, 7);\n+        stream[provider.ConsumeIntegralInRange<size_t>(start, stream.size() - 1)] ^= std::byte{1} << provider.ConsumeIntegralInRange<unsigned>(0, 7);\n     };\n     LIMITED_WHILE (provider.remaining_bytes(), 100) {\n         CallOneOf(provider,\n             // Arbitrary bytes.\n             [&] {\n-                auto bytes = provider.ConsumeBytes<uint8_t>(provider.ConsumeIntegralInRange<size_t>(0, 256));\n-                stream.insert(stream.end(), bytes.begin(), bytes.end());\n+                append_fn(provider.ConsumeBytes<std::byte>(provider.ConsumeIntegralInRange<size_t>(0, 256)));\n             },\n             // The peer's key.\n             [&] {\n-                append_fn(std::span{peer.GetOurPubKey().data(), EllSwiftPubKey::size()});\n+                append_fn(peer.GetOurPubKey());\n                 aad.clear();\n             },\n             // Garbage.\n             [&] {\n-                auto bytes = provider.ConsumeBytes<uint8_t>(provider.ConsumeIntegralInRange<size_t>(0, V2Transport::MAX_GARBAGE_LEN));\n-                stream.insert(stream.end(), bytes.begin(), bytes.end());\n-                aad.insert(aad.end(), bytes.begin(), bytes.end());\n+                const auto garbage{provider.ConsumeBytes<std::byte>(provider.ConsumeIntegralInRange<size_t>(0, V2Transport::MAX_GARBAGE_LEN))};\n+                append_fn(garbage);\n+                aad.insert(aad.end(), garbage.begin(), garbage.end());\n             },\n             // The garbage terminator, possibly corrupted.\n             [&] {\n@@ -471,7 +470,7 @@ FUZZ_TARGET(p2p_transport_v2_receive, .init = initialize_p2p_transport_serializa\n                 const bool corrupt = provider.ConsumeBool();\n                 const auto start = stream.size();\n                 stream.resize(start + contents.size() + BIP324Cipher::EXPANSION);\n-                peer.Encrypt(contents, MakeByteSpan(aad), ignore, MakeWritableByteSpan(stream).subspan(start));\n+                peer.Encrypt(contents, aad, ignore, std::span{stream}.subspan(start));\n                 aad.clear();\n                 if (corrupt) flip_fn(start);\n             }\n@@ -479,7 +478,7 @@ FUZZ_TARGET(p2p_transport_v2_receive, .init = initialize_p2p_transport_serializa\n     }\n\n     // Receive the stream in pieces, and send what the transport wants to send in between.\n-    std::span<const uint8_t> to_recv{stream};\n+    std::span<const uint8_t> to_recv{MakeUCharSpan(stream)};\n     while (!to_recv.empty()) {\n         const size_t piece_len = provider.ConsumeIntegralInRange<size_t>(1, to_recv.size());\n         std::span<const uint8_t> piece = to_recv.first(piece_len);\n```",
   "id": "rc:4433199664",
   "url": "https://github.com/bitcoin/bitcoin/pull/36635#discussion_r4433199664",
   "hash": "d496dd4db0c3992e",
   "edited": "2026-10-31T20:40:23Z"
  },
  {
   "t": "2026-10-31T20:34:07Z",
   "kind": "review_comment",
   "who": "uqlidi",
   "assoc": "NONE",
   "path": "src/test/fuzz/p2p_transport_serialization.cpp",
   "commit": "0b0fa362fe68359f981348611ec050e290de79c1",
   "in_reply_to": null,
   "text": "nit: the code doesn't use `more` or `msg_type`.\n\n```suggestion\n        const auto& [bytes, _more, _msg_type] = transport.GetBytesToSend(/*have_next_message=*/false);\n```",
   "id": "rc:4433202380",
   "url": "https://github.com/bitcoin/bitcoin/pull/36635#discussion_r4433202380",
   "hash": "3b3dc11fabd44653",
   "edited": "2026-10-31T20:40:23Z"
  },
  {
   "t": "2026-10-31T20:36:08Z",
   "kind": "review_comment",
   "who": "uqlidi",
   "assoc": "NONE",
   "path": "src/test/fuzz/p2p_transport_serialization.cpp",
   "commit": "984a01619e2f52b8ab9e4cec7a8ea2c2647724d7",
   "in_reply_to": null,
   "text": "I think the transport never rekeys here. The loop stops at 100 actions, so a stream holds fewer than 100 packets, and the transport rekeys every 224:\nhttps://github.com/bitcoin/bitcoin/blob/a773e1e33b349dc819b5a0edc1c7a4839dc73b48/src/bip324.h#L26\n\nMaybe add an action that appends a run of empty decoys? With the diff below, libFuzzer's coverage report shows this rekey branch hit:\nhttps://github.com/bitcoin/bitcoin/blob/a773e1e33b349dc819b5a0edc1c7a4839dc73b48/src/crypto/chacha20poly1305.cpp#L106-L108\n\n```diff\ndiff --git a/src/test/fuzz/p2p_transport_serialization.cpp b/src/test/fuzz/p2p_transport_serialization.cpp\nindex 852994afe..c823f669d 100644\n--- a/src/test/fuzz/p2p_transport_serialization.cpp\n+++ b/src/test/fuzz/p2p_transport_serialization.cpp\n@@ -474,6 +474,16 @@ FUZZ_TARGET(p2p_transport_v2_receive, .init = initialize_p2p_transport_serializa\n                 peer.Encrypt(contents, MakeByteSpan(aad), ignore, MakeWritableByteSpan(stream).subspan(start));\n                 aad.clear();\n                 if (corrupt) flip_fn(start);\n+            },\n+            // A run of empty decoys, so the transport has to rekey (every REKEY_INTERVAL packets).\n+            [&] {\n+                const auto count{provider.ConsumeIntegralInRange<unsigned>(1, BIP324Cipher::REKEY_INTERVAL)};\n+                for (unsigned i{0}; i < count; ++i) {\n+                    const auto start = stream.size();\n+                    stream.resize(start + BIP324Cipher::EXPANSION);\n+                    peer.Encrypt({}, MakeByteSpan(aad), /*ignore=*/true, MakeWritableByteSpan(stream).subspan(start));\n+                    aad.clear();\n+                }\n             }\n         );\n     }\n```",
   "id": "rc:4433207342",
   "url": "https://github.com/bitcoin/bitcoin/pull/36635#discussion_r4433207342",
   "hash": "d4337982c90c76ae",
   "edited": "2026-10-31T20:40:23Z"
  },
  {
   "t": "2026-11-01T18:47:23Z",
   "kind": "force_push",
   "who": "fametrano",
   "commit": "984a01619e2f52b8ab9e4cec7a8ea2c2647724d7",
   "id": "push:984a01619e2f52b8ab9e4cec7a8ea2c2647724d7"
  },
  {
   "t": "2026-11-01T18:51:26Z",
   "kind": "comment",
   "who": "fametrano",
   "assoc": "NONE",
   "text": "@uqlidi thanks, all seven comments are applied in 984a01619e.\n\nOnce the transport reports v2, the target asserts that its session id matches the peer's; with the check inverted it aborts on the new corpus, so the assert is reached. The old target never rekeyed: a new action appends a run of empty decoys, and replaying the corpora counted 0 rekeys before and 854 after, at about half the exec rate. Both entropies are `const`, from `ConsumeFixedLengthByteVector`. The peer is initialized from `key.EllSwiftCreate(ent)`. `stream` and `aad` hold `std::byte`. The unused bindings are `_more` and `_msg_type`. The refactors read the input the same way: a hash of the stream, the entropy and the bytes left was identical before and after them on all 601 old-corpus inputs with valid keys.\n\nOne note on Ralph's report on 0b0fa362fe: its verifier says the target reaches the wrong-magic disconnect. That branch's log line, `net.cpp:1144` at 984a01619e, had 0 hits in the gist's 10-minute run of the new target and its 5-minute run of the final one (`results.txt`). This is why the description says the target does not reach it: that path needs a v1 `version` header at the start of the stream.\n\nI also rebased: master gave `initialize_p2p_transport_serialization` a reachability goal this target never observes, so it now uses `initialize_p2p_transport`. Commands and output: https://gist.github.com/fametrano/b1d9da3c3312cae5b1029679828ee023/e168d09e55328bc3b2955680154ea4b4687b8e5c",
   "id": "c:6562344437",
   "url": "https://github.com/bitcoin/bitcoin/pull/36635#issuecomment-6562344437",
   "hash": "21caf49469b6e01f",
   "edited": null
  }
 ],
 "labels_log": [
  {
   "t": "2026-10-23T23:46:15Z",
   "action": "labeled",
   "label": "Fuzzing",
   "who": "DrahtBot"
  }
 ],
 "state_log": [],
 "text_chars": 10629,
 "text_tokens_estimate": 2657,
 "changed_paths": [
  "src/test/fuzz/p2p_transport_serialization.cpp"
 ],
 "files": [
  {
   "path": "src/test/fuzz/p2p_transport_serialization.cpp",
   "add": 102,
   "del": 0
  }
 ],
 "test_lines": 102,
 "git": {
  "head": "0b0fa362fe68359f981348611ec050e290de79c1",
  "head_matches_backup": true,
  "base": "a773e1e33b349dc819b5a0edc1c7a4839dc73b48",
  "patch_id": "841523ea0288baf4",
  "commits": [
   {
    "sha": "0b0fa362fe",
    "subject": "fuzz: Add a target that feeds arbitrary bytes to V2Transport",
    "files": 1,
    "add": 102,
    "del": 0
   }
  ],
  "patch_truncated": false
 },
 "patch_id": "841523ea0288baf4",
 "input_hash": "4851261dfdd7cb5a",
 "extracted_at": "2026-11-02T01:09:49+00:00"
}