Skip to content

[Security] DistributedData counts quorum acks and read-responses by the payload's from instead of the authenticated peer, letting one member forge a full quorum and inject arbitrary CRDT state #719

Description

@pathosDev

Component: src/crdt/DistributedData.ts
Severity (assessment): MEDIUM
CWE: CWE-290 (Authentication Bypass by Spoofing)
Related: #574, #582, #121

Cluster._onWire hands the handler the socket-level peer identity established by the hello handshake, but DistributedData.start registers a one-parameter arrow that discards it. onWriteAcknowledgment and onReadResponse then de-duplicate on NodeAddress.fromJSON(message.from).toString() — a field the sender writes. There is no check that the claimed address is an up-member, that it differs from the socket peer, or that it is not the originator itself.

Exploit walkthrough

Preconditions: a 5-node cluster; the attacker controls one member (or, in the plain-TCP default, can speak the protocol). The docs (docs/src/content/docs/distributed-data/quorum-reads-writes.mdx) sell 'all' as "Every up-member replica has acked" and 'majority' for "a balance check before a payment".

Write side:

  1. Node A calls await dd.updateAsync('balance', ..., { consistency: 'all' }). required = 5; A broadcasts a ddata-write-request with pendingId P to the four peers.
  2. The attacker's node receives it and replies with four ddata-write-ack frames on its single connection, with from set to each of the other four members' addresses (all public — they are in cluster gossip).
  3. pending.acks reaches 5 and updateAsync resolves. The three honest replicas never saw the write. The application has been told "every replica has this" and proceeds to release goods / confirm a payment.

Read side (stronger — it corrupts, not just misinforms):

  1. Node A calls await dd.getAsync('balance', { consistency: 'majority' }); required = 3.
  2. The attacker sends two ddata-read-response frames with forged from addresses and an attacker-chosen value (e.g. a GCounter with a huge count, or an LWWRegister with a far-future timestamp).
  3. pending.responses.size >= 3 fires. The forged value is merged and written into the local replica via applyMerged, then re-gossiped from this node to the rest of the cluster on the next tick. One member has laundered arbitrary state through a "majority read" into every replica.

This works identically in a fully mTLS'd cluster: mTLS proves which member is on the socket, and this code never looks.

Evidence — src/crdt/DistributedData.ts

src/crdt/DistributedData.ts:263-273 — the authenticated from is dropped:

    for (const kind of [
      'ddata-gossip', 'ddata-write-request', 'ddata-write-ack',
      'ddata-read-request', 'ddata-read-response',
    ] as const) {
      unsubscribes.push(cluster._onWire(kind, (message) => {
        ref.tell(message as unknown as ActorMessage);
      }));
    }

(compare the available signature, src/cluster/Cluster.ts:341: _onWire(kind: string, handler: (message: WireMessage, from: NodeAddress) => void), and src/cluster/Transport.ts:273-277 where from is the hello-authenticated connection.peer.)

src/crdt/DistributedData.ts:746-757 — write-ack counting:

  private onWriteAcknowledgment(message: DDataWriteAcknowledgmentMessage): void {
    const pending = this.pendingWrites.get(message.pendingId);
    if (!pending) return;
    const senderAddr = NodeAddress.fromJSON(message.from).toString();
    if (pending.acks.has(senderAddr)) return; // dedupe
    pending.acks.add(senderAddr);
    if (pending.acks.size >= pending.required) {
      pending.timer.cancel();
      this.pendingWrites.delete(message.pendingId);
      pending.resolve();
    }
  }

src/crdt/DistributedData.ts:772-794 — read-response counting, and the merged result is written into the local replica:

    const senderAddr = NodeAddress.fromJSON(message.from).toString();
    if (pending.responses.has(senderAddr)) return; // dedupe
    pending.responses.add(senderAddr);
    if (message.value !== null) {
      const incoming = decodeCrdt(message.value);
      pending.merged = pending.merged ? pending.merged.merge(incoming) : incoming;
    }
    if (pending.responses.size >= pending.required) {
      ...
      if (pending.merged) {
        const current = this.view.state.get(pending.key);
        this.applyMerged(pending.key, current ?? null,
          current ? current.merge(pending.merged) : pending.merged);
      }
      pending.resolve(pending.merged);

Why the existing guard does not cover it

The transport does establish and defend a real per-connection identity — Transport.onMessage (src/cluster/Transport.ts:236-277) refuses a duplicate-identity hello ("hello hijack rejected") specifically so connection.peer cannot be spoofed, and only delivers frames once connection.peer is set. Cluster._onWire faithfully forwards that address. The CRDT layer is the only consumer in the repo that throws it away: ClusterClientReceptionist (src/cluster/ClusterClientReceptionist.ts:100) also ignores it, but its handler does not make a trust decision. pending.acks being a Set looks like an anti-double-count guard but keys on the forgeable field, so it counts one attacker as N distinct voters.

Suggested fix

Take the two-argument handler in DistributedData.start and pass the authenticated from through to the actor (e.g. wrap as { ...message, _peer: from }). In onWriteAcknowledgment / onReadResponse, key acks / responses on the authenticated address and reject any frame whose payload from disagrees with it, or whose address is not currently in cluster.upMembers(). Also verify message.key === pending.key. The same substitution fixes onGossip's sender check (line 809-810), which is currently a no-op an attacker can trivially pass.

Relationship to existing issues

Adjacent to #574, #582, #121, but a distinct mechanism. Verified verbatim. Cluster._onWire's signature is (message: WireMessage, from: NodeAddress) => void (Cluster.ts:341) and Transport.ts:277 passes the hello-authenticated connection.peer; DistributedData.start registers a one-parameter arrow that discards it (DistributedData.ts:270-272). onWriteAcknowledgment keys pending.acks on NodeAddress.fromJSON(message.from).toString() (746-757) and onReadResponse does the same for pending.responses, then merges the peer-supplied value and writes it into the local replica via applyMerged (772-794) — all lines exact. Explicitly NOT calibrated out: this is the carve-out case, a handler taking identity from the message payload when the authenticated connection peer is available, so it breaks a fully mTLS'd cluster too. The corpus tracks this exact pattern at three other sites — #574 (Receptionist handleGossip uses message.from and even notes the identical pattern in DistributedPubSubMediator), #582 (mediator handleGossip keys remoteNodes on message.from), #121 (ClusterClientReceptionist routes replies to env.from) — but none of them touches src/crdt, and none has this consequence: forging N distinct voters from one connection defeats the documented 'all'/'majority' consistency guarantee and laundered state is re-gossiped cluster-wide. Own issue, cross-referencing the three.

Verification status

Found in the second, independent whole-framework security re-audit of 2026-08-02 (v0.12.0) — a fresh pass run without reference to the first wave's findings, then triaged against the existing tracker and adjudicated by verifiers instructed to refute it.

Verifier note

Confirmed against source; every cited line is verbatim.

  • src/crdt/DistributedData.ts:262-273 — the for (const kind of [...]) loop registers cluster._onWire(kind, (message) => { ref.tell(...) }), a one-parameter arrow. Exact as quoted.
  • src/cluster/Cluster.ts:341_onWire(kind: string, handler: (message: WireMessage, from: NodeAddress) => void). Exact. handleWire (495-505) falls through to onUnhandledWire(m, from) (511-516), which calls custom(message, from) — the authenticated address really is passed and really is discarded by the CRDT layer. There is no generic message.from === from check anywhere in handleWire.
  • src/cluster/Transport.ts:236-277 — the hello-hijack defence and this.handler(connection.peer, message) at 277 are as described, so from is the handshake-established peer.
  • src/crdt/DistributedData.ts:746-757 and 772-795onWriteAcknowledgment and onReadResponse key pending.acks / pending.responses on NodeAddress.fromJSON(message.from).toString() and the merged read result is written into the local replica via applyMerged (790-791). Exact as quoted.

I looked specifically for the guard the finder says is missing and did not find one: no up-member check, no comparison against the socket peer, no message.key === pending.key check, and DistributedDataOptionsValidator (invoked at DistributedData.ts:244) validates only option fields. There is no test covering a spoofed fromtests/multi-node/distributed-data-consistency.test.ts exercises the quorum arithmetic only, and there is no tests/unit/crdt/DistributedData.test.ts (only DurableDistributedData.test.ts). The existingGuards note about ClusterClientReceptionist.ts:100 also checks out — it likewise registers a one-parameter handler and re-derives the sender from env.from at :102.

Quorum arithmetic holds: clampQuorum (903-912) gives required = N for 'all' and floor(N/2)+1 for 'majority', and acks/responses start with self (662, 698), so one peer supplying required − 1 frames with distinct forged from values satisfies the threshold. pendingId is known to the attacker because the request is broadcast to it (688, 725). The documentation quotes are accurate — docs/src/content/docs/distributed-data/quorum-reads-writes.mdx:67 says 'all' means every up-member replica has acked, and :46-47 offers a pre-payment balance check as the motivating 'majority' read.

Not calibrated out: this is the payload-identity-instead-of-authenticated-peer case, so it breaks a fully mTLS'd cluster too, where the attacker is one compromised member rather than any TCP speaker.

Severity corrected HIGH → MEDIUM. Two reasons. First, the impact correction above: the alarming half of the claimed consequence (arbitrary CRDT state reaching every replica) is already available to any member through the unauthenticated gossip merge, so the genuine delta is the forged ack/response count defeating the documented 'all'/'majority' attestation — real, but narrower than stated, and the page itself already disclaims linearizability (:192-206) and resolves reads best-effort on timeout (707-711). Second, tracker calibration: the three sibling issues for the identical pattern — #574 (Receptionist gossip), #582 (DistributedPubSubMediator subscription wipe), #121 (ClusterClient reply rerouting) — all carry severity: medium, and this instance is not materially worse than #582. The suggested fix (pass the authenticated from through and key the sets on it, plus the message.key === pending.key check and the observation that the onGossip self-check at 809-810 is a no-op against a spoofer) is sound.

Correction applied: Three impact/detail corrections. (1) The read-side claim that the spoof lets an attacker "inject arbitrary CRDT state into the local replica" and "launder it into every replica" overstates the delta of this bug: onGossip (DistributedData.ts:808-817) merges any peer's entries unconditionally and gossipTick (866-882) re-broadcasts the whole local state every interval, so a malicious member can already push arbitrary CRDT state cluster-wide with no spoofing at all. The incremental harm of the payload-from bug is the forged quorum attestation (and the early resolution that excludes honest responders), not the state injection. (2) Write-side exploit step 3 ("The three honest replicas never saw the write") is wrong in the normal case — onUpdate broadcasts the ddata-write-request to every up peer (687-689), so honest replicas do apply it. The real write-side harm is narrower: when honest replicas are genuinely partitioned or failing, the quorum timeout that should surface that (667-675) is converted into a false success. (3) Counting detail: acks/responses are pre-seeded with self (662, 698), so the attacker needs required − 1 frames; the exploit's totals are right, but of the four acks on a 5-node 'all' write one is the attacker's genuine ack and three are forged.

Second opinion (independent, citation lens)

Verified every cited line against the source; the mechanism is exactly as described.

  • src/crdt/DistributedData.ts:270-272 registers a one-parameter arrow, discarding the second argument.
  • src/cluster/Cluster.ts:341 declares _onWire(kind: string, handler: (message: WireMessage, from: NodeAddress) => void), and Cluster.ts:511-516 (onUnhandledWire) does call custom(message, from) with the socket peer, which src/cluster/Transport.ts:277 supplies as connection.peer. The identity really is on offer and really is thrown away.
  • src/crdt/DistributedData.ts:746-757 keys pending.acks on NodeAddress.fromJSON(message.from).toString(); 772-794 does the same for pending.responses and then merges the peer-supplied value into the local replica via applyMerged. No membership check, no self-check, no message.key === pending.key check. I grepped tests/ and src/ for ddata-write-ack / ddata-read-response: the only hits are in DistributedData.ts itself, so there is no guard elsewhere and no test asserting one. The quorum arithmetic is as claimed (clampQuorum, 903-912; self pre-seeded as one vote at 663 and 698), so in a 5-node cluster with 'all' four forged acks on one socket complete the write.

This is the payload-identity carve-out rather than the conceded plain-TCP wire: one member casting N votes defeats 'majority' / 'all' in an mTLS'd cluster too, and the documented guarantees (docs/src/content/docs/distributed-data/quorum-reads-writes.mdx — "Every up-member replica has acked", "a balance check before a payment") are exactly what breaks. Not refuted.

I did correct the impact. The finding's headline consequence — arbitrary CRDT state injected into the replica and re-gossiped cluster-wide — is not enabled by this bug. onGossip (808-817) merges any peer's entries unconditionally and gossipTick (866-882) spreads them, and the attacker's own legitimately-attributed read-response is merged anyway (778-781), so an up-member has that capability without forging anything. What the forgery uniquely buys is (a) updateAsync(..., 'all') resolving when zero honest replicas hold the write — a durability lie whose window is the originator dying before the next gossip tick (default 1000 ms, line 567) — and (b) getAsync(..., 'majority') completing on attacker frames alone, so the merge omits honest replicas' newer state and returns a stale value labelled majority-confirmed. Real, worth fixing, but a lying counter rather than new write access.

I also corrected "hello-authenticated": Transport.ts:222-247 takes connection.peer from the peer's self-declared message.self, with first-connection-wins as the only defense; nothing binds a TLS certificate to a NodeAddress. Counting by socket peer is still the right fix (one connection, one vote) and I endorse the suggested patch including the key check and the onGossip sender fix at 809-810 — just not on the grounds that the socket peer is cryptographically proven.

Severity: HIGH → MEDIUM. The precondition is a compromised up-member (the pendingId only reaches upMembers()), and that attacker already has unchecked write access to every replica through gossip. Against the repo's bar of 4 critical / 5 high in 65, a broken acknowledgment count for an already-trusted replica sits below the same-pattern siblings' ceiling, not above it.

Correction applied: Three corrections to the framing, none of which removes the defect.

  1. "Inject arbitrary CRDT state into the local replica" (title) and "Read side (stronger — it corrupts, not just misinforms)" overstate the delta. onGossip (src/crdt/DistributedData.ts:808-817) merges every entry of a ddata-gossip frame from any peer with no check at all beyond sender.equals(selfAddress), and gossipTick (866-882) pushes full state onward, so any up-member can already write attacker-chosen CRDT state into every replica cluster-wide with zero forgery. Separately, on the read path the attacker's own, correctly attributed ddata-read-response already carries an arbitrary value that is merged into pending.merged and then into the local replica via applyMerged (778-792) — spoofing from is not what enables the state injection. The non-redundant defect is only the vote count.

  2. "The hello-authenticated connection.peer" / "mTLS proves which member is on the socket" is inaccurate. Transport.onMessage (src/cluster/Transport.ts:222-247) sets connection.peer from message.self — the address the peer asserts in its own hello frame. There is no certificate-to-address binding anywhere in Transport.ts (no getPeerCertificate, no CN check), and the documented dev TLS setup shares one cert across all nodes (docs/src/content/docs/operations/security/cluster-security.mdx). The hijack guard is first-connection-wins, not cryptographic. Using from is still the right fix — it makes one socket one vote — but it is a per-connection stable identity, not an authenticated one.

  3. The attacker must be an up-member: both pendingId values only reach peers via upMembers() (660-690, 694-726). (nextPendingId at 886-890 is p${Date.now()}-${counter} and therefore guessable by a non-member, but exploiting that reduces to the conceded unauthenticated-wire model.)

Severity HIGH → MEDIUM on that basis: the attacker is already a fully trusted replica in a replication design with no Byzantine model, and the surviving incremental capability is a false acknowledgment count, not new write access.

Second opinion (independent, impact lens)

Verified against source and reproduced with a probe.

Source, all citations exact:

  • Cluster._onWire(kind, handler: (message: WireMessage, from: NodeAddress) => void) at src/cluster/Cluster.ts:341; the dispatch that supplies from is onUnhandledWire at src/cluster/Cluster.ts:511-517, fed by handleWire(from, message) from transport.setHandler (src/cluster/Cluster.ts:405).
  • The authenticated peer is real: TcpTransport.onMessage sets connection.peer only from a hello/hello-ack, rejects a duplicate-identity hello ("hello hijack rejected", src/cluster/Transport.ts:236-270), drops any frame arriving before the handshake (:272-275), and then calls this.handler(connection.peer, message) (:276).
  • DistributedData.start registers a one-parameter arrow for all five ddata-* kinds and forwards the raw frame to the actor, discarding from (src/crdt/DistributedData.ts:262-273).
  • onWriteAcknowledgment keys pending.acks on NodeAddress.fromJSON(message.from).toString() (src/crdt/DistributedData.ts:746-757); onReadResponse does the same for pending.responses and then merges the peer-supplied value and calls applyMerged on the local replica when the count is reached (:772-794). No check against cluster.upMembers(), no comparison to the socket peer, no message.key === pending.key check.

Guards I looked for and did not find: nothing in DistributedDataOptionsValidator, nothing in Cluster.handleWire (it only bumps the failure detector), nothing in MultiNodeTransport/TcpTransport beyond peer binding, and no test in tests/multi-node/distributed-data-consistency.test.ts that exercises a mismatched sender.

Probe (outside the repo, real modules, MultiNodeSpec + MultiNodeTransport, which passes the true sender as from — src/testkit/internal/MultiNodeTransport.ts:56-59, so it models an authenticated peer faithfully). DistributedData was started only on the originator a, so no honest ack/response can exist; node c registered its own _onWire handler and replied over its own legitimate connection.

  • consistency: 'all', 3 nodes, required 3 — control (attacker sends 1 ack under its real address): REJECTED after 1526ms … (2/3 acks). Forged (2 acks, one claiming b): RESOLVED after 1ms. The originator was told every replica had the write; b never saw it.
  • consistency: 'majority', 5 nodes, required 3, local value 1 — control (1 truthful response carrying an attacker GCounter of 1_000_000): returned 1000001 after 1512 ms via the timeout path, local replica unchanged at 1. Forged (2 responses claiming b and d): returned 1000001 after 1 ms and local replica = 1000001, which gossipTick then pushes to the rest of the cluster.

Not calibrated out. The socket peer in both runs was correctly c; the code never reads it. This is the payload-identity-over-authenticated-peer carve-out, so mTLS does not help — a single compromised or misbehaving member defeats the documented 'all'/'majority' guarantee (docs/src/content/docs/distributed-data/quorum-reads-writes.mdx:67 "Every up-member replica has acked", overview.mdx:131).

Side-claims spot-checked and accurate: ClusterClientReceptionist does register a one-parameter handler and takes identity from env.from (src/cluster/ClusterClientReceptionist.ts:100-102); onGossip's only sender check is sender.equals(selfAddress) (src/crdt/DistributedData.ts:809-810), which a forged from trivially passes.

One thing the finding does not claim but is worth recording alongside the fix: nextPendingId() is p${Date.now()}-${counter} (src/crdt/DistributedData.ts:887-890), i.e. guessable. It is not needed for this exploit — the attacker is handed the pendingId in the request — but it widens the window for an off-path sender once the identity check is added, so the fix should not rely on pendingId secrecy.

Severity: HIGH, not critical. The precondition is one already-admitted cluster member behaving maliciously, and quorum reads/writes are opt-in per call; but the consequence — defeating a documented consistency guarantee and persisting attacker-chosen CRDT state into a replica that re-gossips it — is squarely at the HIGH bar for this catalogue.

Correction applied: Two precision fixes to the exploit narrative, both confirmed by a live probe:

(1) Write side, ack arithmetic. The attacker does NOT need to forge all four peer addresses. onUpdate pre-seeds pending.acks with the originator's own address (DistributedData.ts:662), so an ack claiming the originator's address is deduped and wasted. In a 5-node 'all' write (required = 5) one malicious member needs exactly 4 acks: its own real address plus 3 forged peer addresses. Reproduced at N=3: one forged ack (as b) plus the attacker's own resolved consistency: 'all' in 1 ms.

(2) Read side, what the forgery actually buys. The finding implies the attacker-chosen value reaching the caller is a consequence of the forged quorum. It is not — onReadResponse merges every response's value into pending.merged unconditionally (DistributedData.ts:778-781), and the read timeout path resolves with pending.merged rather than rejecting (DistributedData.ts:703-712). So a single peer already steers the returned value of a quorum read with one truthful response; my control run returned 1000001 on a timeout. What the forged quorum adds — and it is the serious part — is the early-satisfaction branch, which alone calls applyMerged (DistributedData.ts:784-791): only there does the attacker's CRDT get written into the originator's local replica, from which gossipTick (DistributedData.ts:866-882) pushes the whole local state to a random peer every tick, laundering it cluster-wide. My control run left the local replica at 1; the forged run left it at 1000001. The issue text should say the forgery converts a transient wrong answer into persistent, re-gossiped replica corruption.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingpriority: mediumUseful, not urgentproduction-goalBlocks or defines the path to production readinesssecuritySecurity-relevant — see severity label for impact tierseverity: mediumModerate impact or requires specific conditions

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions