Skip to content

[Security] release() silently no-ops while an acquire is in flight, making LeaseMajority's #142 abandon-release and fail-safe inert for both shipped backends #600

Description

@pathosDev

Component: src/coordination/leases/KubernetesLease.ts
Severity (assessment): LOW
CWE: CWE-693

LeaseMajority.runAbandonRelease calls lease.release() to undo an acquire that may have landed on the server after the local timeout, and enters fail-safe if that release rejects. Both shipped backends begin release() with if (!this.held) return;, and held is false for the entire duration of an in-flight acquire — so the release is a no-op that resolves successfully, the fail-safe branch is unreachable, and the abandoned acquire still completes and starts a renewal loop.

Exploit walkthrough

An attacker who can slow one node's coordination backend past acquireTimeoutMs (default 5 s) — flooding the K8s API server, or degrading the path to it — during an equal-size partition: node A's LeaseMajority.decide hits the deadline, bumps the epoch and calls runAbandonRelease(). KubernetesLease.release() sees held === false (it is only set at KubernetesLease.ts:146/172, after the POST/PUT resolves), returns immediately without issuing the DELETE, and does not throw — so failSafe is never set and the strategy believes the wire state was cleaned up. The in-flight POST/PUT then lands: the API server records A as holder, tryAcquireOnce sets held = true and startRenewalLoop() keeps renewing it indefinitely, while A's resolver discarded the result and recorded no win. The lease is now pinned to a node that never observed winning it; every future arbitration is decided in that node's favour regardless of which side is healthy, and the documented protection against the 'stale-token split-brain vector' provides nothing.

Evidence — src/coordination/leases/KubernetesLease.ts:191

src/coordination/leases/KubernetesLease.ts:190-191:

  async release(): Promise<void> {
    if (!this.held) return;

src/coordination/leases/InMemoryLease.ts:99-100 is identical:

  async release(): Promise<void> {
    if (!this.held) return;

src/cluster/downing/LeaseMajority.ts:254-265 depends on it doing real work:

  private async runAbandonRelease(): Promise<void> {
    const myEpoch = this.acquireEpoch;
    try {
      await this.options.lease.release();
    } catch {
      ...
      if (myEpoch === this.acquireEpoch) {
        this.failSafe = true;
      }

Why the existing guard does not cover it

I checked whether a regression test pins the behaviour, and the test that exists gives false assurance: FencedFakeLease.release() in tests/integration/in-process/cluster/downing/DowningStrategies.test.ts:374-377 is async release() { if (this.releaseShouldReject) throw ...; this.released = true; } — it has no held state, so it always 'releases', unlike every real backend. The tests at lines 431 and 450 ('timeout proactively releases the lease…', 'release rejection puts the strategy in fail-safe…') therefore pass against semantics no shipped Lease implements. I also checked the epoch guard (LeaseMajority.ts:240) — that part is genuinely effective and correctly tested; it is only the release/fail-safe half that is inert.

Suggested fix

Make release() cancel an in-flight acquire rather than early-returning: track the in-flight state (e.g. an acquiring flag plus an abort epoch checked in tryAcquireOnce before it sets held/starts the renewal loop) and, when a release arrives during an acquire, mark the attempt abandoned so its success is undone (DELETE, or a PUT clearing holderIdentity) instead of promoted to a held lease. Then update FencedFakeLease to model the held gate so the #142 tests actually exercise the real contract.

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

The mechanism is verified and is in fact slightly worse than described: KubernetesLease.ts:191 if (!this.held) return; guards a held flag set only at lines 146 and 172 (after the POST/PUT resolves), so it is false for the whole in-flight window; InMemoryLease.ts:100 is identical. Moreover KubernetesLease.release() swallows every error it could raise (getCreds via .catch(() => null) at :197, deleteLease inside try/catch at :199-205), so it never rejects at all — meaning the catch at LeaseMajority.ts:257-264 that sets this.failSafe = true is unreachable for both shipped backends regardless of held. The test double at tests/integration/in-process/cluster/downing/DowningStrategies.test.ts:374-377 indeed has no held state, so the #142 tests do not pin the real contract.

Correction applied: Downgraded from medium to low, and the split-brain consequence does not follow. If the abandoned acquire lands, the node genuinely holds the K8s lease and renews it — so the opposing partition's acquire returns 'held-by-other' and mutual exclusion still holds; there is no state where both sides win. What is actually broken is that a documented defence-in-depth path (#142 proactive release + fail-safe) is inert and a lease can stay held after the strategy considered the attempt abandoned. The 'attack' also requires degrading a node's path to the K8s API past acquireTimeoutMs during an exactly-equal-size partition, which is not a realistic attacker-controlled trigger.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingpriority: lowNice-to-have / niche / demand-drivenproduction-goalBlocks or defines the path to production readinesssecuritySecurity-relevant — see severity label for impact tierseverity: lowMinor / informational / mitigated-by-design

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions