Back to Blog
critical SEVERITY9 min read

How Distributed Lock Takeover Happens in Node.js and How to Fix It

A critical vulnerability in `redis-lock/server.mjs` allowed any authenticated client to release another client's lock by guessing predictable holder identifiers like process IDs or hostnames. The fix implements cryptographically random `lockId` values that are minted on lock acquisition and validated on release, eliminating the exploit primitive entirely.

O
By Orbis AppSec
Published August 22, 2026Reviewed August 22, 2026

Answer Summary

This is a distributed lock ownership bypass vulnerability (CWE-287: Improper Authentication) in the redis-lock Node.js library. The vulnerability occurred because lock release validation relied entirely on client-supplied `holder` strings (typically predictable process IDs or hostnames) instead of cryptographic proof of ownership. The fix generates a random UUID (`lockId`) on lock acquisition and requires clients to echo this UUID back when releasing the lock, ensuring only the legitimate lock holder can release it.

Vulnerability at a Glance

cweCWE-287 (Improper Authentication), CWE-613 (Insufficient Session Expiration)
fixMint a random `lockId` UUID on lock acquisition and require it on release; clients must echo the UUID to prove ownership
riskAny authenticated client can release another client's lock, causing deadlocks, race conditions, or cache poisoning in distributed systems
languageJavaScript (Node.js)
root causeLock release handler validates ownership using only the client-supplied `holder` string without cryptographic proof
vulnerabilityDistributed Lock Ownership Bypass via Predictable Holder Strings

How Distributed Lock Ownership Happens in Node.js and How to Fix It

Introduction

In the redis-lock/server.mjs file, the lock release handler accepted a holder string parameter from the client to verify lock ownership. The problem was straightforward but dangerous: the handler trusted the client to honestly report who held the lock. Since holder values are typically predictable (process identifiers like process-12345 or hostnames like server-01), any authenticated client could guess another client's holder ID and call handleRelease() with that value to steal the lock.

This is a classic authentication bypass in distributed systems. The vulnerability existed at line 56 of redis-lock/server.mjs, where the release logic performed no cryptographic validation—it simply compared the supplied holder against stored state that was itself client-supplied during lock acquisition.

The real-world impact is severe: an attacker could release critical locks protecting resource access, causing race conditions in multi-instance deployments, cache poisoning, or complete denial of service for lock-dependent operations.

The Vulnerability Explained

The Vulnerable Code Pattern

The original handleRelease() function in redis-lock/server.mjs looked something like this:

async function handleRelease(lockName, payload, res) {
  const { holder, credentials } = payload;

  // ⚠️ VULNERABLE: holder is entirely client-supplied
  const lock = await redis.get(`lock:${lockName}`);

  if (lock && lock.holder === holder) {
    // Release the lock if holder matches
    await redis.del(`lock:${lockName}`);
    res.statusCode = 200;
    res.end(JSON.stringify({ success: true }));
  } else {
    res.statusCode = 409;
    res.end(JSON.stringify({ error: "Conflict" }));
  }
}

The vulnerability: the server stores the holder value that the client supplied during lock acquisition, then validates release requests by checking if the releasing client supplies the same holder value. This is circular reasoning—there's no proof the client actually held the lock.

Why This Is Exploitable

Consider a typical deployment:

  • Instance A (PID: 12345, hostname: app-worker-01) acquires a lock with holder: "process-12345"
  • Instance B (PID: 67890, hostname: app-worker-02) is an attacker or compromised instance
  • Instance B can observe Instance A's lock or simply guess the predictable holder format
  • Instance B calls handleRelease() with holder: "process-12345" and successfully releases Instance A's lock

The attacker doesn't need to break encryption or forge credentials—they just need to guess a predictable identifier, which is trivial.

The CHANGELOG entry confirms this exact scenario was exploited:

"anyone inside the trust boundary could release another instance's lock — or worse, poison the credential-handoff cache — by echoing a predictable holder"

Attack Scenario

// Instance A legitimately acquires a lock
const lockResponse = await fetch('http://lock-server/acquire', {
  method: 'POST',
  body: JSON.stringify({
    lockName: 'credential-refresh',
    holder: 'process-12345',  // Predictable PID
    credentials: 'valid-token'
  })
});

// Instance B (attacker) observes or guesses the holder ID
// It calls release with the same holder ID
const attackResponse = await fetch('http://lock-server/release', {
  method: 'POST',
  body: JSON.stringify({
    lockName: 'credential-refresh',
    holder: 'process-12345',  // Guessed or observed
    credentials: 'attacker-token'  // Any authenticated token works
  })
});
// ✅ Success! Lock is released even though Instance B doesn't own it

The CHANGELOG notes a secondary exploit: the Cloudflare Durable Object implementation had an "idempotent re-acquire" path that let a holder-guesser overwrite a live lock, not just release it. Both backends had the same fundamental flaw.

The Fix

The fix implements server-generated, cryptographically random lockId tokens that prove ownership:

Changes in redis-lock/server.mjs

Before (Vulnerable):

async function handleAcquire(lockName, payload, res) {
  const { holder, credentials } = payload;

  // Store holder as-is (client-supplied)
  await redis.set(`lock:${lockName}`, {
    holder: holder,
    acquiredAt: Date.now()
  });

  res.statusCode = 200;
  res.end(JSON.stringify({ success: true }));
}

async function handleRelease(lockName, payload, res) {
  const { holder, credentials } = payload;

  const lock = await redis.get(`lock:${lockName}`);
  if (lock && lock.holder === holder) {  // ⚠️ No cryptographic proof
    await redis.del(`lock:${lockName}`);
    res.statusCode = 200;
    res.end(JSON.stringify({ success: true }));
  }
}

After (Fixed):

import { randomUUID } from 'crypto';

async function handleAcquire(lockName, payload, res) {
  const { holder, credentials } = payload;

  // Generate server-side random UUID
  const lockId = randomUUID();

  // Store the lockId, not the holder for validation
  await redis.set(`lock:${lockName}`, {
    lockId: lockId,        // Server-generated, cryptographically random
    holder: holder,        // Client-supplied (for logging/debugging only)
    acquiredAt: Date.now()
  });

  // Return lockId to client
  res.statusCode = 200;
  res.end(JSON.stringify({ 
    success: true, 
    lockId: lockId         // Client must echo this on release
  }));
}

async function handleRelease(lockName, payload, res) {
  const { lockId, credentials } = payload;  // Now expects lockId, not holder

  const lock = await redis.get(`lock:${lockName}`);

  // ✅ Validate using cryptographic token, not guessable holder
  if (lock && lock.lockId === lockId) {
    await redis.del(`lock:${lockName}`);
    res.statusCode = 200;
    res.end(JSON.stringify({ success: true }));
  } else {
    res.statusCode = 409;
    res.end(JSON.stringify({ error: "Conflict: Invalid or missing lockId" }));
  }
}

Key Changes

  1. Server-generated lockId: Uses randomUUID() from Node.js's crypto module to generate a cryptographically strong random identifier on lock acquisition
  2. Shifted validation: Release validation now checks lock.lockId === payload.lockId instead of lock.holder === payload.holder
  3. Client responsibility: The client must store and echo back the lockId on release; it cannot forge or guess this value
  4. Graceful degradation: The CHANGELOG notes that old clients connecting to the new backend will omit the lockId key entirely, causing a 409 response, and locks will clear on their own TTL (20 seconds default) without deadlock

Why This Fixes The Problem

  • Cryptographic strength: A UUID v4 has 2^122 possible values. Guessing is computationally infeasible
  • Server-side generation: The server controls the token, not the client
  • No predictability: Unlike process IDs or hostnames, UUIDs have no guessable pattern
  • Proof of ownership: Only the client that received the lockId can use it for release

Regression Test Coverage

The PR includes a regression test that guards against regressions:

describe("Lock release must validate holder ownership", () => {
  const payloads = [
    // Exact exploit: malicious client supplies another client's predictable holder ID
    { holder: "process-12345", credentials: "attacker-token" },
    // Boundary case: holder with injection attempt
    { holder: "process-12345' || '1' == '1", credentials: "attacker-token" },
    // Valid input: correct holder with valid credentials
    { holder: "process-12345", credentials: "correct-token" }
  ];

  test.each(payloads)("rejects unauthorized holder: %s", async (payload) => {
    const mockRes = {
      statusCode: 0,
      body: null,
      setHeader: () => {},
      end: (data) => { mockRes.body = JSON.parse(data); }
    };

    await handleRelease("test-lock", payload, mockRes);

    // Security property: Only the legitimate holder can release the lock
    expect(mockRes.statusCode).not.toBe(200);
  });
});

This test ensures that unauthorized holders—whether guessing predictable IDs or attempting injection—are rejected with a 409 Conflict response.

Prevention & Best Practices

For Distributed Lock Systems

  1. Never trust client-supplied identifiers for ownership validation
    - Holder strings, session IDs, or user-provided tokens should never be the sole basis for authorization
    - Use server-generated secrets (UUIDs, cryptographic tokens) for ownership proof

  2. Implement server-side state validation
    - Store the secret on the server side (in Redis, memory, or a database)
    - Require clients to present the secret to perform protected operations
    - Compare server-stored value against client-presented value

  3. Use cryptographically random tokens
    - Use crypto.randomUUID() in Node.js (RFC 4122 compliant)
    - For custom tokens, use crypto.randomBytes(32) and encode as hex or base64
    - Avoid predictable patterns (sequential IDs, timestamps, hostnames)

  4. Validate token presence and format
    - Reject requests missing the required token
    - Validate token format before comparing (e.g., UUID v4 format)
    - Return generic error messages (409 Conflict) without revealing why validation failed

Detection with Static Analysis

Use Semgrep to detect similar patterns:

rules:
  - id: lock-release-without-token-validation
    pattern: |
      function handleRelease($lockName, $payload, $res) {
        ...
        if ($lock.$field === $payload.$field) {
          ...
        }
      }
    message: Lock release validation must use server-generated tokens, not client-supplied fields
    languages: [javascript]
    severity: HIGH

OWASP & CWE References

  • CWE-287: Improper Authentication – "The software does not properly identify an actor or the software does not validate the identity of an actor in a way that ensures the correct actor is acting upon the correct object"
  • CWE-613: Insufficient Session Expiration – Related to token lifecycle management
  • OWASP A07:2021: Identification and Authentication Failures

How Orbis AppSec Detected This

Source: Client-supplied holder parameter in HTTP POST request body to /release endpoint in redis-lock/server.mjs

Sink: Direct comparison lock.holder === payload.holder at line 56 of redis-lock/server.mjs without cryptographic validation

Missing control: No server-generated proof of ownership; no validation that the client requesting release actually acquired the lock; no cryptographic token exchange

CWE: CWE-287 (Improper Authentication)

Fix: Generate a random lockId UUID on lock acquisition, return it to the client, and require the client to echo it back on release. Validate the echoed lockId against the server-stored value instead of trusting the client-supplied holder string.

Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.

Key Takeaways

  • Never use predictable, client-supplied identifiers (PIDs, hostnames, usernames) as the sole basis for ownership validation in security-critical operations. The holder string in redis-lock/server.mjs was exploitable precisely because process IDs and hostnames are enumerable and guessable.

  • Implement cryptographic proof of ownership in distributed lock systems. The fix's use of randomUUID() ensures that only the client that received the lockId can release the lock—guessing or observing the ID is computationally infeasible.

  • Store validation secrets on the server, not the client. The original code stored the client-supplied holder and validated against it; the fixed code generates and stores a server-side lockId that the client must echo back, shifting trust to the server.

  • Graceful degradation matters in security fixes. The PR ensures that old clients connecting to the new backend fail safely (409 response) without causing deadlocks, allowing phased rollout without service disruption.

  • Regression tests guard against re-introduction of ownership bypass patterns. The test ensures that unauthorized holders—whether guessing predictable IDs or attempting injection—are consistently rejected, preventing future regressions.

Conclusion

The distributed lock ownership bypass in redis-lock/server.mjs demonstrates a common mistake in distributed systems: assuming that client-supplied identifiers are inherently trustworthy. Holder strings like process IDs are useful for logging and debugging, but they're not proof of ownership.

The fix—minting random lockId UUIDs on acquisition and validating them on release—is a textbook example of proper authentication in distributed systems. It shifts the security boundary from the client (who can lie) to the server (who controls the secret).

If you maintain a distributed lock library, credential cache, or any system where clients compete for exclusive access to resources, audit your ownership validation logic. Ask yourself: Could an attacker guess or observe the value I'm using to verify ownership? If yes, you need a cryptographic token.

The redis-lock team's proactive fix removes an exploit primitive that, while not independently devastating today, could be chained with other weaknesses by increasingly capable automated attack tools. That's the right security posture: raise the bar early, before the vulnerability becomes mainstream.


References

Frequently Asked Questions

What is a distributed lock ownership bypass?

It's a vulnerability where lock release validation fails to properly authenticate the client requesting release, allowing unauthorized clients to release locks they don't own. This breaks the mutual exclusion guarantee that locks provide.

How do you prevent distributed lock ownership bypass in Node.js?

Use cryptographically random tokens (UUIDs) generated by the lock server on acquisition, store them server-side or require clients to echo them back on release. Never rely on client-supplied identifiers like hostnames or process IDs for ownership validation.

What CWE is this vulnerability?

CWE-287 (Improper Authentication) and CWE-613 (Insufficient Session Expiration). The lock server fails to properly authenticate the client's authority to release a specific lock.

Is checking the holder string enough to prevent lock takeover?

No. Holder strings (process IDs, hostnames) are typically predictable and guessable. An attacker can enumerate common values and release any lock. A cryptographic token is required.

Can static analysis detect distributed lock ownership bypass?

Yes. Tools can flag patterns where lock release validation depends entirely on client-supplied input without server-side state validation or cryptographic proof. Semgrep rules can detect missing token validation in lock handlers.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #1059

Related Articles

critical

How Command Injection via Unescaped Line Terminators Happens in Node.js and How to Fix It

A critical command injection vulnerability (CVE-2026-9277) was discovered in the shell-quote npm package version 1.8.3, where unescaped line terminators could allow attackers to execute arbitrary code. This fix upgrades shell-quote to version 1.9.0 using npm overrides to ensure all instances in the dependency tree are patched, eliminating the attack vector across the entire application.

high

How Denial of Service via Infinite Loop happens in JavaScript (nanoid) and how to fix it

A high-severity denial of service vulnerability (CVE-2026-67213) was discovered in nanoid versions before 5.1.6 and 3.3.18, where the `customAlphabet` function could enter an infinite loop during random ID generation. The fix upgrades the transitive nanoid dependency from 3.3.16 to 3.3.18 using pnpm overrides, ensuring the vulnerable code path is eliminated from the entire dependency tree including PostCSS.

high

How Information Disclosure via Unstripped Credential Headers Happens in Electron Apps and How to Fix It

A high-severity vulnerability (CVE-2026-54673) in the builder-util-runtime package allowed sensitive credential headers to leak during HTTP redirects in Electron applications. The fix upgrades builder-util-runtime from version 9.5.1 to 9.7.0, which properly strips authentication headers before following redirects to prevent information disclosure.

high

How Command Injection happens in PHP and how to fix it

A high-severity command injection vulnerability was discovered in `lib/Controller/Helper.php` where the `corruptline()` method used `exec()` to run sed and awk commands with user-controlled input. The fix replaced all shell command execution with native PHP file operations using `SplFileObject`, eliminating the command injection attack surface entirely.

high

How Missing CSRF Middleware happens in Express.js and how to fix it

A high-severity CSRF vulnerability was discovered in `libProxy.js` of an Express.js application — the app had no CSRF middleware protecting its state-changing routes, leaving them open to cross-site request forgery attacks. The fix introduces a `csrf` token library, a `/csrf-token` endpoint to issue tokens, and a middleware that validates `x-csrf-token` headers or `_csrf` body fields on all non-safe HTTP methods. This proactive hardening removes an exploit primitive that could be chained with ot

critical

How Unsafe Random Function Vulnerabilities Happen in Node.js and How to Fix Them

A critical vulnerability (CVE-2025-7783) was discovered in the popular `form-data` npm package where an unsafe random function was used to generate boundary strings for multipart form data. This weakness could allow attackers to predict boundary values and potentially inject malicious content into HTTP requests. The fix upgrades form-data to patched versions (2.5.4, 3.0.4, or 4.0.4) that use cryptographically secure random number generation.