Insights

The Manifest-Revocation Flood

Operators are seeing peers drop with onReadMessage: No message of desired type right after negotiation. It is not a local fault, and it is not confined to hubs — we see it on an ordinary full-history node. Here is what that node logged, the exact line in the rippled source that produces the error, and the raw logs to check against yours.

Captured on our node (xrpld 3.2.0, build 3c43f46) · 2026-07-31 · source cited at rippled 3c43f46

Revocations
223,303
in 10.28s
Key algorithm
100% secp256k1
real validators use ed25519
Disconnect events
141k+
680 distinct peers, 3.4 hours
Consensus impact
none
proposing throughout
Fixed upstream — 2026-08-01

rippled 3.2.1 closes this. Upgrade. On our node the storm is still running at roughly 40,000 disconnects an hour with no sign of decay, so this is not something to wait out. The release notes say only “hotfix”, but the diff is aimed squarely at this:

  • The cache now caps manifests for keys it does not list at 100 (kMaxUntrustedCount), with a new UntrustedCapacity rejection.
  • Untrusted gossip for a brand-new key is no longer relayed, which breaks the amplification loop that let one flood converge onto every node.
  • The manifest message is now bounded — all trusted plus at most 200 untrusted (kMaxManifestsPerMessage), a ceiling of ~71.5 KiB instead of the whole cache. It is also shuffled, so the untrusted budget rotates.
  • An oversized manifest message is now dropped without dropping the peer.
Bottom line
  • The storm is network-wide, and it is not specific to hubs — the node below is an ordinary full-history node. Failures start at one instant and then accumulate as connections cycle, reaching 680 distinct peers across both inbound and outbound links. No local config breaks hundreds of independent peers, including ones we dialed out to.
  • Our node stayed proposing with 3.0 s convergence throughout. This is connection churn, not a ledger problem. Don't panic-restart.
  • The spam lives only in the in-memory manifest cache — nothing was written to disk.
Jul 31 · 02:45:30first fabricated manifest02:45:40blast ends · 223,303 manifests02:46:16peers start dropping46 s
A single burst. Peer disconnects begin 46 seconds after the first fabricated manifest lands.

1. What arrived

A validator manifest binds a validator's master key to its current signing key. A manifest whose sequence is 4294967295 std::numeric_limits<uint32_t>::max() — is a revocation, permanently retiring that key (Manifest.cpp:237). Manifests are gossiped so the network agrees on who the validators are.

Between 02:45:30.302 and 02:45:40.585 UTC on Jul 31 — 10.28 seconds — our node accepted 223,303 revocations for keys it had never seen, logging 454,894 Manifest: Revoked lines (Manifest.cpp:420).

That is two lines per manifest, not two manifests — worth knowing before you compare counts with anyone. applyManifest runs its prewriteCheck once under a shared lock and then again under the write lock, and the warning sits inside it (Manifest.cpp:468). In the published log no key appears fewer than twice, and 217,462 appear exactly twice. Halve any raw grep -c.

They are cryptographically valid and self-signed — the signature check runs before that log line (Manifest.cpp:404), so these are real signatures over keys the sender generated. Every one of the 223,303 keys is secp256k1 (n9…), while the real validator manifests on our disk are all ed25519 (nH…). Bulk-generated, in other words, and on no UNL.

We checked that last part rather than assuming it. Sampling the local node's validations stream for 150 seconds gives 111 distinct validator keys 94 of them on mainnet (36 on the dUNL, 58 outside it), plus 17 that publish coherent alternate chains at roughly 1.67M, 4.09M and 19.5M ledger index while still declaring network_id 0. Cross-referencing every one of those keys against all 223,346 revoked keys returns zero matches. Nothing in the flood is impersonating a validator that actually validates; the keys were minted for this.

2. The mechanism, line by line

"No message of desired type" is the POSIX ENOMSG string — boost::system::errc::no_message. In rippled it has exactly one source: the reader could not match the first byte of a frame header against either valid shape (ProtocolMessage.h:229). Four facts, all in the source, turn a bloated manifest cache into that byte:

  1. The whole cache goes out as one frame. Right after the handshake a node sends its entire manifest set (PeerImp.cpp:932), serialized by getManifestsMessage() with no size cap (OverlayImpl.cpp:1204).
  2. The length field is 26 bits. The sender packs the payload size as (size >> 24) & 0x0F into byte 0 (Message.cpp:176), and the reader only accepts an uncompressed frame when (byte0 & 0xFC) == 0(ProtocolMessage.h:205). So a payload of 226 bytes (64 MiB) or more sets a bit that must be clear.
  3. The size guard cannot catch it. kMaximumMessageSize is megabytes(64) = 226 (Message.h:14), but the largest size an uncompressed header can even express is 226−1. The message_size branch (ProtocolMessage.h:332) is therefore unreachable for uncompressed frames — you get no_message, never “Message too long”. Our log bears this out: 141,404 of the former in the published capture alone, and zero of the latter.
  4. Nothing on the send side checks. Message::Message never compares against kMaximumMessageSize (Message.cpp:21). An over-ceiling frame is built and sent happily.

It is not a crypto-verification DoS, and the fakes were not accepted unverified. Asked which of those it was, we checked both. The master signature is verified inside prewriteCheck before a manifest is logged or stored (Manifest.cpp:404) — the flood passed, because a self-signed manifest over a key you generated yourself is genuinely valid. Validity was never the filter, and nothing checks the key against a UNL before caching it. Nor is rejection expensive: a re-offered manifest short-circuits on the staleness check, which sits above the signature check, so it costs a map lookup and no ECDSA — we have logged 1,155,418 such rejections over ~11 hours (223,303 of those keys are the flood set) with no peer impact. Verification is also off the read path entirely: onMessage(TMManifests) hands the batch to the job queue rather than verifying on the peer strand (PeerImp.cpp:1134). The one job that did the work is in our log — Job: RcvManifests run: 10569ms wait: 0ms, about 47µs per signature, and crucially wait: 0ms, so nothing queued behind it. It finished at 02:45:40.871, a further 36 seconds before the first disconnect. The cost that matters is not CPU, it is the retained size of what was accepted.

byte 00cmp0cmp0cmp0cmp0sz270sz260sz251sz24must be zero — any 1 here is the dead zone0x01parsedpayload 25.1 MiBceiling 64 MiB = 2^26
Drag the slider. Byte 0 carries payload-size bits 24–27; the six high bits must be clear. Past ~570,000 manifests the size overflows into them and the receiver reports errc::no_message.

The arithmetic: a revocation manifest is 107 bytes for ed25519 (measured) and ~114 for secp256k1, plus 4 bytes of protobuf framing per entry (xrpl.proto:38) — call it 111118 bytes on the wire. The 64 MiB ceiling therefore lands at roughly 568,719604,584 manifests.

Measured, not inferred. This was the one step on the page we could only deduce — until we read the node's own overlay traffic counters before upgrading. Over 2.7 days our node sent 1,831,459 manifest messages totalling 157.4 TB, a mean frame of 85,941,263 bytes (82.0 MiB) 1.28× the 64 MiB ceiling. Put that size through the sender's own packing, (size >> 24) & 0x0F, and byte 0 is 0x05: inside the 0x04–0x7F dead zone, so no receiver can frame it. The counter is the real wire buffer (PeerImp.cpp:293), and manifests were 99.72% of every outbound byte our node produced, from 0.23% of its messages.

The asymmetry is its own evidence. Inbound manifest messages average only 0.38 MiB, because inbound bytes are recorded after a successful parse — the oversized frames never get counted, they die in parseMessageHeader first. What the inbound counter shows is the survivors; what the disconnect log shows is the rest. And since every node converged on the same bloated cache, this is symmetric: we could not read theirs, and they could not read ours.

flood source223,303 new keysManifestCacheseq 4294967295= Accepted, in memorypeer 1peer 2peer 3peer 4peer 5…everyconnectedpeer
Why every node converges upward: an accepted manifest is immediately re-sent to every other connected peer, so each node's cache grows toward the union of all keys the attacker distributed.

That relay is unconditional — anything returning Accepted is forwarded to all peers (OverlayImpl.cpp:709). And when the frame fails to parse, fail() logs and closes with no resource charge(PeerImp.cpp:658), so nothing throttles a peer that keeps doing it. That gap is filed upstream as rippled #7491, which names the same 0x04–0x7F “dead zone” byte and notes fail is called without fee_.update(Resource::feeInvalidData).

3. The disconnects

Beginning 02:46:16.658 46 seconds after the first manifest — our node logged a sustained storm of Peer:WRN […] onReadMessage: No message of desired type: 141,404 events over the 3.4 hours of the published capture, across 680 distinct peer node keys. There were zero in the preceding five weeks of logs (2026-Jun-27 onward), and the storm was still running when this was published — so treat any total as a floor, not a final count. That zero includes the 37 hours this node had already been running the same xrpld 3.2.0 build before the flood, which rules out the build change itself as the cause.

Those peers do not all fail at once, and that matters when you are diagnosing it. A node sends its manifest set right after the handshake, so a peer can only break when it (re)connects — distinct peers therefore accumulate rather than drop together: 9 in the first minute, 135 by five, 639 by thirty. The onset is an instant; the spread is a ramp.

It is also not over. Re-measured at 10:21 UTC, 7h35m after onset, the rate is essentially unchanged: ~746/min, 339,366 events, across 735 distinct peers — up from the 680 in the published capture, so peers are still joining the failing set rather than draining out of it.

The shape is the useful part. Our established peer set sits stable at about 41, while those 735 identities have failed roughly 460 times each. Both are true because the failures never become peers at all: a connection completes the handshake, receives the oversized manifest frame, dies, and retries. So this does not read as “losing peers” on a graph of peer count — it reads as a stable core beside a large population stuck in a connect → handshake → drop loop. It also explains why a hub feels it worst: more peers means more of them looping, and the frame a node ships on handshake is the thing that kills them.

A second, unrelated failure mode corroborates the timing. onReadMessage: stream truncated is a different error on a different path, and it ran at a quiet 531 and 605 occurrences on Jul 29 and Jul 30. On Jul 31 it hit 17,995 — and every one of them landed after the onset, the first at 02:46:19, three seconds behind the first no_message drop, with none at all earlier in the day. Two independent error paths turning on together, three seconds apart, after a quiet baseline, is not something a local misconfiguration produces.

inbound123,082 inbound · 87%18,322 outbound · 13%Outbound = remote port is the peer's listening port (51235 or 2459), i.e. a connection we opened.
Both directions fail. That connections we dialed out to hundreds of independent peers break at all is what rules out a local cause — an outbound failure cannot be our listener, our firewall, or our config.

4. Consensus was never affected

server_state
proposing
converge_time_s
3.001
proposers
33
validated age
1 s

A point-in-time server_info read taken while the disconnect storm was running. proposers and validated-ledger age move every ledger; server_state is the one that matters, and it never left proposing.

Both list publishers stayed available with 36 trusted validators, and wallet.db was unmodified since before the flood. That is by design, not luck: a manifest is only persisted if its master key is on a published validator list (OverlayImpl.cpp:694). Fabricated keys are not, so the flood inflated memory and gossip only.

5. What operators should do

grep -c 'Manifest: Revoked' debug.log # halve it — each manifest logs twice
grep -m1 'No message of desired type' debug.log
  • Upgrade to 3.2.1. This is the fix. Both gaps this page reported — an uncapped cache for unknown keys, and an unbounded manifest message — are closed, and a patched node stops feeding the flood onward to everyone else.
  • Don't panic-restart an unpatched node. It clears your cache but not your peers', so the churn returns. Consensus is unaffected either way.
  • Watch server_state. While you are proposing or full, you are fine.

6. What 3.2.1 changed, and what it left alone

The release notes say only “hotfix”, but the 3.2.0→3.2.1 diff is 13 files and the manifest and overlay paths are most of it.

  • Cache growth is capped. At most 100 master keys the node does not list (Manifest.h). Every call site must now state a cap policy explicitly, so nothing is left uncapped by omission.
  • Amplification is broken. A manifest is relayed only if the key is trusted or already known (OverlayImpl.cpp:727) — “untrusted gossip for a brand-new key cannot be amplified.” That is the loop in the diagram above.
  • The message is bounded. All trusted plus at most 200 untrusted, shuffled — about 71.5 KiB, not the whole cache. A manifest larger than 358 bytes is now rejected before parsing.
  • Oversize no longer costs the connection. An over-cap manifest message is consumed and ignored rather than disconnecting, and the sender is charged instead.

Two things it deliberately did not do, and both are worth knowing. The new receiver-side check runs after the header is parsed, so it cannot catch the exact frame we saw: a message at or over 64 MiB still fails in parseMessageHeader with errc::no_message and still drops the peer with no charge. What actually protects you is the sender cap — which means an un-upgraded peer with an already-bloated cache can still knock over a patched node. And the 26-bit ceiling itself is untouched: kMaximumMessageSize is still exactly 226, so its guard remains unreachable for uncompressed frames, and #7491 is still open as filed. The manifest flood is fixed; the framing edge it exposed is not.

One detail for anyone grepping their own logs: the double-logging is unchanged in 3.2.1. prewriteCheck still runs twice with the warning inside it, so grep -c 'Manifest: Revoked' still returns twice the number of manifests.

7. How this page's reading held up

A patch is the only honest scoreboard for an incident writeup, so here is ours against it — what we called correctly, what we called wrongly, and what never occurred to us.

Held up
  • The root cause. An uncapped cache for keys the node does not list — now kMaxUntrustedCount. This was in the first version published, before any of our own corrections.
  • The amplification loop drawn in the diagram above — now the relay gate.
  • No sender-side bound on the manifest frame — now kMaxManifestsPerMessage.
  • The operational calls, all measured here rather than predicted: network-wide rather than anyone's config, consensus untouched, nothing written to disk, and a restart clearing only your own cache — the last now echoed by 3.2.1's own note that entries are never evicted.
Got wrong
  • We first named the missing fee on the disconnect path as the second gap. Wrong lever. 3.2.1 leaves it alone, and a fee would not have helped anyway: the peers sending us broken frames were hundreds of honest nodes relaying what they had legitimately accepted, so charging them would have penalised the victims. The attacker connected once and never triggered that path at all. We later replaced it with the sender-side size limit, which is what was actually fixed.
Missed
  • We described the amplification loop but never proposed breaking it. The relay gate is arguably the most effective single change in 3.2.1, since it is what stops a cache converging on the union of everything sprayed at the network, and it was not on our list.
  • A per-manifest size bound (kMaxManifestBytes) did not occur to us at all.
  • We left the frame-overflow step as an inference for a day longer than we had to. The node's own overlay traffic counters settle it — a 82.0 MiB mean manifest frame, byte 0 of 0x05 — and they were readable the whole time. We reached for the debug log and the source and never thought to ask the node what it had actually been sending. It is also a counter that resets on restart, so the answer would have been gone the moment we upgraded.
  • Upstream still never confirms the crossing, and their receiver-side cap only fires on frames that do parse — a case our reading does not require. That may point at a second failure mode we did not see, and we have no evidence either way.

Download the evidence

Raw excerpts from our node, exactly as captured on 2026-07-31. SHA-256 prefixes let you verify integrity; all five match the counts quoted above.

Timestamps UTC. Node identities are public overlay keys; IPs are as observed on the wire. Published for verification and incident response — this is an analysis of network traffic, not an attribution of any operator.

Sources

  • rippled source, pinned at 3c43f4614f87965298773279ff5b85d4c56c637b release-3.2, the exact tree our node runs: Manifest.cpp, ProtocolMessage.h, Message.cpp, Message.h, PeerImp.cpp, OverlayImpl.cpp, Wallet.cpp, xrpl.proto
  • XRPLF/rippled #7491 — parseMessageHeader dead-zone byte triggers unconditional peer disconnect (2026-06-10)
  • xrpl.org — Understanding log messages
  • Our node: xrpld 3.2.0, release-3.2 @ 3c43f4614f87965298773279ff5b85d4c56c637b; logs and wallet.db as published above.
  • Every line above is cited on the build our node was running, so the code and the logs are the same tree. We also diffed it against develop @ 2403670d: Manifest.cpp is byte-identical and the overlay files differ only in position, so the mechanism is unchanged on both. If you are reading a newer tree, search for the expressions rather than the line numbers.

Related: XRPL amendments & amendment-blocking