A reth message that decodes cleanly never trips the misbehaviour counter, and that was the problem
Before reth PR #23718, the eth-wire Transactions and PooledTransactions message bodies decoded their entire RLP list into an unbounded Vec before any per-transaction validation ran, so a single well-formed message at or below the 10 MB wire limit could allocate up to 436 MB during decode while producing no decode error and therefore no peer-misbehaviour signal.
Why this matters
This one is already fixed. The decode budget landed in #23718 on 2026-04-27, and the operator action is simply to be on a build that includes it. What makes it worth writing down is the shape of the failure, not the urgency.
reth has a defence against peers that send bad data: reth_network_invalid_messages_received, a misbehaviour counter that leads to disconnection. That counter reacts to decode errors. This message produces no decode error. It is structurally valid RLP, it decodes cleanly, and the counter never moves. The peer is eventually disconnected at pool validation, when the signatures turn out to be junk, but by then the allocation has already happened.
So the defence is present, correct for what it was built to catch, and silent for this. That is the transferable part: a validity check placed after an unbounded allocation protects the wrong side of the expensive step, and an abuse detector keyed on malformedness cannot see an attack made of well-formed input.
The second detail worth carrying is where the size ratio comes from. The wire format is compact and the decoded representation is not. Each decoded transaction occupies about 416 bytes, so a message packed with minimal transactions expands by a factor the wire limit does not describe. MAX_MESSAGE_SIZE bounds what arrives, not what it becomes.
How the attack works
Entry point. The devp2p eth-wire surface: an unsolicited Transactions broadcast, or PooledTransactions.
Preconditions. A remote peer that has completed the RLPx handshake and the eth Status exchange. One message is enough.
Processing path. In crates/net/eth-wire-types/src/broadcast.rs and .../transactions.rs, the message bodies derive their decoder through RlpDecodableWrapper:
#[derive(... RlpDecodableWrapper ...)]
pub struct Transactions<T = TransactionSigned>(pub Vec<T>);
PooledTransactions has the same shape. The derived Decodable implementation decodes the whole RLP list into Vec<T> with no running memory bound.
Boundary crossed. On the receive path the message is decoded in full before transactions are validated. Signature recovery is lazy and happens after decode, so the allocation is paid for any structurally valid list, including one whose per-element signatures are meaningless. The attacker never needs a valid signature, only valid structure.
Why the existing defence does not fire. Because the bytes decode without error, reth_network_invalid_messages_received is not incremented during decode. Disconnection comes later, at pool validation, after the memory spike.
Resulting behaviour. Peak decode allocation far above the wire size, repeatable per peer. N concurrent peers each sending one such message drive proportional memory pressure on the node.
The fix. #23718 adds decode_with_memory_budget and swaps it into the receive path in .../message.rs, with tx_memory_budget = MAX_MESSAGE_SIZE * 2 = 20 MB, aborting decode once the running allocation would exceed the budget. The bound is on the allocation, not on the wire size, which is the right place for it.
Affected systems
- Chain: Ethereum (execution layer).
- Implementation: reth (
paradigmxyz/reth), builds before #23718, which merged 2026-04-27. - Component: eth-wire
TransactionsandPooledTransactionsdecode. - Not affected: any build including #23718.
This is a documented pre-fix residual. The advisory exists to record the measured magnitude for operators still running older builds, not to report a live unpatched defect.
Evidence
Publicly documented. The fix is public as paradigmxyz/reth PR #23718, merged 2026-04-27. No CVE was assigned.
Provenance matters here and is worth stating plainly: this finding came out of a PR-mining cycle, meaning it was found by reading reth's already-merged fix rather than by independent discovery of an unknown defect. The PR's own description had already characterised the problem, noting the decoded memory footprint "might be up to 20x bigger" and calling the worst case "a DOS from multiple peers quite bad". NullRabbit did not find this before the vendor, and does not claim to.
What this work adds is the precise magnitude in place of the PR's rough estimate (436 MB and 41.6x, measured against the real decode path, versus "up to 20x"), and the analysis of why the existing peer-misbehaviour defence never fires.
Independently reproduced by NullRabbit. The published corpus reproducer captures the attack traffic, a post-handshake Transactions broadcast carrying a large well-formed transaction list, against a protocol-compatible node. NRDAX records this instance at fidelity: lab, discovery_origin: original-research. The advisory is explicit that it stands on the source trace and the harness measurement rather than on the reproducer's transport shim.
Measured under controlled conditions. Measured against reth's exact reth_eth_wire_types::PooledTransactions decode path, using a workspace harness built on reth source at the pre-fix parent commit, with a counting global allocator recording peak allocation:
| Payload (well-formed, decodes OK, no misbehaviour bump) | transactions in one message at or below 10 MB | peak decode allocation | amplification |
|---|---|---|---|
| minimal legacy transactions | 1,048,576 | 436 MB | 41.6x |
| realistic legacy transaction (reth's own encode test vector) | 99,864 | 54.5 MB | 5.2x |
Per decoded transaction, elem_size is 416 bytes. Both rows exceed the 20 MB budget #23718 introduced, which is the point of quoting both: the realistic-payload case is nearly eight times less amplified than the synthetic one and still clears the new bound.
Inferred but not reproduced. Fleet-scale impact from N concurrent peers is described in the advisory as proportional. The proportionality follows from the per-peer measurement; a multi-peer run was not measured.
Operator actions
- Upgrade to a reth build including #23718. That is the fix. The decode budget bounds
TransactionsandPooledTransactionsdecode to 20 MB.
If you are running a pre-#23718 build and cannot upgrade immediately, note that the usual peer-reputation lever does not help here, because the messages are well-formed and do not register as misbehaviour. Memory limits at the process level (a cgroup MemoryMax with a restart policy) bound the consequence rather than preventing the allocation; that is general operational practice rather than a mitigation the advisory specifies.
Canonical references
- NRDAX technique: NRDAX-T0328 (
unbounded-request-body-memory-exhaustion), instancereth_pooledtx_decode_memory_amp - NullRabbit advisory: NR-2026-019, published 2026-07-06
- Upstream fix:
paradigmxyz/reth#23718, merged 2026-04-27 - Evidence bundle: primitive
reth_pooledtx_decode_memory_ampin the public datasetNullRabbit/nr-bundles-public - Source trace, harness and numbers:
chains/ethereum/findings/RETH_TX_MEMORY_BUDGET/(residual-measurement-2026-07-06/) - Upstream source:
crates/net/eth-wire-types/src/broadcast.rs,.../transactions.rs,.../message.rs
Related attacks
NRDAX-T0328 spans multiple chains under the same unbounded-request-body-memory-exhaustion technique. This instance (reth_pooledtx_decode_memory_amp) shares the technique with prysm_bls_pool_decode_burn (Ethereum Prysm, covered in the Prysm attestation pool brief), cardano_submit_api_body_memory_pin (Cardano, brief), ic_orchestrator_cup_body_bomb (Internet Computer), prysm_attester_slashing_pubkey_burn and prysm_bls_exec_change_decode_burn (Ethereum Prysm), and monero_wallet_rpc_body_size_dos (Monero). This brief describes only the reth instance.
The Prysm sibling is the closest comparison and the contrast is instructive: both decode an attacker-sized list before applying a bound, but Prysm spends the excess on CPU (a per-element BLS subgroup-check) while reth spends it on memory. Same missing bound, different resource, and in reth's case the bound now exists.
Track attacks affecting reth and Ethereum execution clients in NRDAX.
Analysis plus reproducer. No weaponised proof-of-concept code.
Related Posts
How a zero-value GetBlockHeaders request underflows to serve go-ethereum's entire header chain (CVE-2024-32972)
A go-ethereum eth-protocol peer that sends a GetBlockHeaders request with amount (count) set to 0 triggers an integer underflow — count-1 wraps to UINT64_MAX — bypassing the maxHeadersServe cap and forcing the node to serve headers back to genesis, a publicly disclosed memory-exhaustion DoS (CVE-2024-32972, fixed in geth 1.13.15) that NullRabbit reproduced as a lab detection bundle.
The Cosmos-SDK gRPC server never sets MaxConcurrentStreams, and a fork already fixed it
Cosmos-SDK builds its gRPC server without grpc.MaxConcurrentStreams, so grpc-go defaults to an effectively unbounded stream count and advertises no cap to clients: each held stream pins roughly 7 KB of server state, reaching 7.6 GB RSS at the top of the tested sweep with no plateau in sight.
Prysm's attestation pool endpoint does the BLS work before it checks the list length
Prysm's POST /eth/v2/beacon/pool/attestations decodes an unbounded JSON list and runs a BLS G2 subgroup-check per element before any cap applies, turning a 59 MB request into 5.81 seconds of single-goroutine CPU on an exposed Beacon API.
