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.
Component:
src/coordination/leases/KubernetesLease.tsSeverity (assessment): LOW
CWE: CWE-693
LeaseMajority.runAbandonReleasecallslease.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 beginrelease()withif (!this.held) return;, andheldis 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'sLeaseMajority.decidehits the deadline, bumps the epoch and callsrunAbandonRelease().KubernetesLease.release()seesheld === 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 — sofailSafeis 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,tryAcquireOncesetsheld = trueandstartRenewalLoop()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:191Why 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 isasync release() { if (this.releaseShouldReject) throw ...; this.released = true; }— it has noheldstate, 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 shippedLeaseimplements. 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. anacquiringflag plus an abort epoch checked intryAcquireOncebefore it setsheld/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 clearingholderIdentity) instead of promoted to a held lease. Then updateFencedFakeLeaseto model theheldgate 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 aheldflag 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 thecatchat LeaseMajority.ts:257-264 that setsthis.failSafe = trueis unreachable for both shipped backends regardless ofheld. The test double at tests/integration/in-process/cluster/downing/DowningStrategies.test.ts:374-377 indeed has noheldstate, 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.