CVE-2026-33306: bcrypt on JRuby is broken at cost=31

I found my first CVE! 馃榿

An integer overflow in bcrypt-ruby鈥檚 JRuby backend that silently skips the entire key-strengthening loop at cost=31. The hash looks valid, verifies correctly but doesn鈥檛 actually add any protection.

Integer overflow: 999 + 1 = 000 when you run out of digits Integer overflow: 999 + 1 = 000 when you run out of digits

Why password hashing matters

You never store passwords in plain text. If your database gets breached, you don鈥檛 want the attacker to just read everyone鈥檚 password. So you hash them. The problem is that plain hashes (SHA-256, MD5, etc.) are fast. An attacker with a GPU can try billions per second. That鈥檚 where libraries like bcrypt, scrypt, and Argon2 come in. They鈥檙e deliberately slow. You pick a cost factor that controls how many rounds of work each hash takes, so even if someone gets the hash, cracking it takes an impractical amount of time.

The bug

bcrypt鈥檚 whole point is that cost factor. Cost=12 means 2^12 rounds of key-strengthening. Cost=31 should be ~2 billion rounds. In BCrypt.java, the round count is computed as:

int rounds = 1 << log_rounds;
for (int i = 0; i < rounds; i++) {
    key(password, sign_ext_bug, safety);
    key(salt, false, safety);
}

Java鈥檚 int is signed 32-bit. 1 << 31 = 0x80000000 = -2147483648. The loop condition 0 < -2147483648 is immediately false. Zero iterations. Cost=31 skips strengthening entirely.

The C implementation uses unsigned int, so MRI (the default Ruby interpreter) is fine. Only JRuby is affected because Java has no unsigned integer type.

How I found it

Was adding bcrypt to a project and did a quick audit of the source. Noticed the same file has two implementations of the same calculation:

// Line 671 -- correct, uses long:
static long roundsForLogRounds(int log_rounds) {
    return 1L << log_rounds;
}

// Line 698 -- vulnerable, uses int:
rounds = 1 << log_rounds;

The safe version exists. It just isn鈥檛 called. crypt_raw() rolls its own round count with a plain int instead of calling roundsForLogRounds().

Wrote a quick PoC to confirm:

// Hash with cost=4 (minimum, baseline)
String hash4 = BCrypt.hashpw(password, BCrypt.gensalt(4));   // 7 ms

// Hash with cost=31 (should take years)
String hash31 = BCrypt.hashpw(password, BCrypt.gensalt(31)); // 0 ms

// The hash verifies correctly, but with zero key-strengthening
BCrypt.checkpw(password, hash31); // true

Cost=31 was faster than cost=4. Should have been 134 million times slower.

In practice

Nobody would ever set cost=31. Each hash would take years to compute and no one wants to wait years to sign in. Most apps use cost 10-14.

The more interesting angle is how bcrypt verification works. A bcrypt hash looks like $2a$12$salthere...hashhere.... That $12$ is the cost factor, baked into the string. When your app calls checkpw(password, hash), it reads the cost from the hash itself and re-hashes the password with the same cost to compare. Your app doesn鈥檛 choose the cost during verification, the hash does.

So if your app verifies hashes it didn鈥檛 generate (database migrations, SSO, imports), an attacker could supply a $2a$31$ hash and the zero-round path gets hit. Your app has no say in it. Niche, but worth knowing about.

The fix

Three lines. Use long, call the method that already existed:

long rounds = roundsForLogRounds(log_rounds);
for (long r = 0; r < rounds; r++) {

Patched in bcrypt-ruby 3.1.22. Full details in the GitHub advisory. If you鈥檙e on JRuby, it鈥檚 pretty low risk but worth upgrading.

Pretty chuffed to have found my first CVE, even if it鈥檚 a bit theoretical. One for the wall.