Skip to content

[Security] ShardRegion.onHandOff destroys a shard's entities on the word of any peer #584

Description

@pathosDev

Component: src/cluster/sharding/ShardRegion.ts
Severity (assessment): MEDIUM
CWE: CWE-940

sharding.HandOff is a coordinator-only directive, but the region executes it for any sender: it marks the shard handing-off, drops the entity bookkeeping, and calls shard.stop(), which terminates every entity actor under it. Nothing checks that the message came from the node hosting the coordinator, and no rebalanceInProgress-style correlation exists on the region side.

Exploit walkthrough

Attacker = any host that completes the hello handshake. It sends {$t:'sharding.HandOff', shardId: n} to actor-ts://<system>/system/cluster/sharding/region-<type> on each node, for n = 0…numShards-1. Each frame stops the shard actor: all live entities in it are terminated (in-memory state gone, in-flight messages in their mailboxes dropped), buffered traffic stalls while shardState is 'handing-off', and completeHandOff then deletes the region's cached shardHomes entry so the next message re-asks the coordinator — which, chained with finding 1, hands the region the attacker's own node as the new home. A few dozen bytes per shard, repeatable indefinitely, and the coordinator never learns anything is wrong because it ignores the unsolicited HandOffComplete.

Evidence — src/cluster/sharding/ShardRegion.ts:720

private onHandOff(message: HandOff): void {
    const shardId = message.shardId;
    const entityIds = Array.from(this.shardEntities.get(shardId) ?? []);
    
    this.shardState.set(shardId, 'handing-off');
    const ack: BeginHandOffAcknowledgment = { $t: 'sharding.BeginHandOffAcknowledgment', shardId };
    this.tellCoordinator(ack);
    
    this.forgetShardEntities(shardId);

    const shard = this.shards.get(shardId);
    if (!shard) { this.completeHandOff(shardId); return; }
    this.handingOff.add(shardId);
    shard.stop();

Why the existing guard does not cover it

Looked for a sender/origin check in handleShardingMessage (it dispatches purely on $t; this.sender is never consulted, and the remote delivery path Cluster.dispatchEnveloperef.tell(body) supplies no sender at all), for a handoff correlation token (the coordinator tracks rebalanceInProgress but sends nothing the region can verify — HandOff carries only shardId), and for a registered/leader precondition (the field this.registered exists but is only used for logging; nothing gates onHandOff on it). No test sends a sharding.HandOff to a region.

Suggested fix

Gate the coordinator-only variants (HandOff, ShardHome, RememberedEntities, RegisterAcknowledgment) on the message having arrived from the node the region currently believes hosts the coordinator, by dispatching the region through the from-carrying per-path envelope handler; drop and log anything else. A correlation nonce echoed from BeginHandOff would additionally stop a stale-but-authentic coordinator frame from being replayed.

Verification status

Found in the whole-framework security audit of 2026-08-01 (v0.12.0), then adjudicated by an independent verifier instructed to refute it.

Verifier note

ShardRegion.onHandOff (ShardRegion.ts:720-741) executes unconditionally: sets shardState to 'handing-off', calls forgetShardEntities(shardId) and shard.stop(). handleShardingMessage (473-494) dispatches on $t alone and never reads this.sender; the remote delivery path (Cluster.ts:651 refOpt.value.tell(decoded.body as never)) supplies no sender at all, so no origin check is even possible today. this.registered is set in onRegisterAcknowledgment (529) and never gates anything.

Correction applied: The impact is self-healing rather than persistent: completeHandOff (743-758) drops the cached home, so the next user message re-asks the coordinator and the shard is re-allocated and respawned. What the attacker gets is repeatable termination of in-memory entity state plus a routing stall — a cheap sustained DoS, not a takeover — which is a medium, not a high, especially next to finding 2 on the same wire-access precondition.

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