bcrypt.compare() accepts a non-canonical hash string when a valid bcrypt hash is followed by a NUL byte (\x00) and arbitrary suffix data.
This happens because the native binding passes the hash as a NUL-terminated C string (encrypted.c_str()), so the underlying bcrypt implementation ignores everything after the first \0.
As a result:
- JavaScript treats the string as distinct
- Native bcrypt truncates at \0 and validates the original hash
Impact
Non-canonical hashes can validate successfully
Application logic relying on raw hash identity may be bypassed
Cross-language inconsistency:
- Node bcrypt: accepts
- Python bcrypt: rejects
This may affect systems using multiple services (Node + Python) or performing hash validation/migration outside Node.
Proof of Concept (Node)
const bcrypt = require("bcrypt");
(async () => {
const password = "test-password";
const hash = await bcrypt.hash(password, 10);
const nulVariant = hash + "\x00ATTACKER_SUFFIX";
const dotVariant = hash + ".ATTACKER_SUFFIX";
console.log("compare(hash):", await bcrypt.compare(password, hash));
console.log("compare(nulVariant):", await bcrypt.compare(password, nulVariant));
console.log("compare(dotVariant):", await bcrypt.compare(password, dotVariant));
})();
Output:
compare(hash): true
compare(nulVariant): true
compare(dotVariant): false
Cross-language behavior (Python)
import bcrypt
password = b"test-password"
hash = b"$2b$10$..." # same hash from Node
nulVariant = hash + b"\x00ATTACKER_SUFFIX"
print(bcrypt.checkpw(password, hash)) # True
print(bcrypt.checkpw(password, nulVariant)) # False
Root Cause
In bcrypt_node.cc:
bcrypt(input.c_str(), input.length(), encrypted.c_str(), bcrypted);
Password is length-aware
Hash is passed as NUL-terminated C string
This causes truncation at the first \0.
Expected Behavior
bcrypt.compare() should reject non-canonical hash inputs (e.g., containing \x00 or extra trailing data).
Suggested Fix
Validate hash format before calling native bcrypt
Reject inputs containing \x00
Enforce canonical bcrypt length (60 chars)
bcrypt.compare() accepts a non-canonical hash string when a valid bcrypt hash is followed by a NUL byte (\x00) and arbitrary suffix data.
This happens because the native binding passes the hash as a NUL-terminated C string (encrypted.c_str()), so the underlying bcrypt implementation ignores everything after the first \0.
As a result:
Impact
Non-canonical hashes can validate successfully
Application logic relying on raw hash identity may be bypassed
Cross-language inconsistency:
This may affect systems using multiple services (Node + Python) or performing hash validation/migration outside Node.
Proof of Concept (Node)
const bcrypt = require("bcrypt");
(async () => {
const password = "test-password";
const hash = await bcrypt.hash(password, 10);
const nulVariant = hash + "\x00ATTACKER_SUFFIX";
const dotVariant = hash + ".ATTACKER_SUFFIX";
console.log("compare(hash):", await bcrypt.compare(password, hash));
console.log("compare(nulVariant):", await bcrypt.compare(password, nulVariant));
console.log("compare(dotVariant):", await bcrypt.compare(password, dotVariant));
})();
Output:
compare(hash): true
compare(nulVariant): true
compare(dotVariant): false
Cross-language behavior (Python)
import bcrypt
password = b"test-password"
hash = b"$2b$10$..." # same hash from Node
nulVariant = hash + b"\x00ATTACKER_SUFFIX"
print(bcrypt.checkpw(password, hash)) # True
print(bcrypt.checkpw(password, nulVariant)) # False
Root Cause
In bcrypt_node.cc:
bcrypt(input.c_str(), input.length(), encrypted.c_str(), bcrypted);
Password is length-aware
Hash is passed as NUL-terminated C string
This causes truncation at the first \0.
Expected Behavior
bcrypt.compare() should reject non-canonical hash inputs (e.g., containing \x00 or extra trailing data).
Suggested Fix
Validate hash format before calling native bcrypt
Reject inputs containing \x00
Enforce canonical bcrypt length (60 chars)