The Seventy-Two Byte Password — JavaScript Bug Hunt

Modelled on Okta's October 2024 advisory: Okta generated the cache key for AD/LDAP delegated authentication by running bcrypt over the user id, username and…

  • Language: JavaScript
  • Layer: Backend
  • Difficulty: Hard
  • Concepts: Security, Auth, Caching
  • Modelled on: Okta · 2024
  • Visible tests: an ordinary account caches and checks its key; a long username does not make the password optional
  • Reward: 50 XP for a complete fix

Briefing

Modelled on Okta's October 2024 advisory: Okta generated the cache key for AD/LDAP delegated authentication by running bcrypt over the user id, username and password joined together. bcrypt only reads the first 72 bytes of its input, so for usernames of 52 characters or more the password fell partly or wholly past that limit — and under certain conditions a login with any password could match a previously cached key. Okta switched the key generation to PBKDF2.

This project is a reconstruction. bcrypt.js (locked) truncates to 72 UTF-8 bytes exactly like the real thing. cache.js builds its key from the concatenated fields.

Fix cacheKey and matches so the key depends on every byte of all three fields.

Bug report

BUG-OKTA-1030 · Priority: Critical (auth bypass) · Reported by: security

cacheKey(userId, username, password) returns a bcrypt hash (bcrypt.hash output) and matches(stored, userId, username, password) says whether the triple produced that key. Requirements:

  • the key must depend on every byte of userId, username and password, however long they are and whatever characters they use (length limits are in UTF-8 bytes, so a 31-character name of "é" is 62 bytes)
  • triples that differ in any field never share a key, including splits of the same text, e.g. ("u1", "ab", "c") vs ("u1", "a", "bc")
  • matches(cacheKey(a, b, c), a, b, c) is true

digest.sha256hex(text) returns a fixed-length 64-character digest of all of its input.

Observed: for a 63-character username, a cached key matches any password.

Logs

[ad-agent] unreachable; using delegated-auth cache
[auth] user 00u1abcdef login ok (cache hit) password_len=5
[auth] user 00u1abcdef login ok (cache hit) password_len=19

The code as shipped

src/auth/cache.js (editable)

var bcrypt = require("./bcrypt");

// Cache key for delegated (AD/LDAP) authentication, consulted when the
// directory agent cannot be reached.
function material(userId, username, password) {
  return userId + username + password;
}

exports.cacheKey = function (userId, username, password) {
  return bcrypt.hash(material(userId, username, password));
};

exports.matches = function (stored, userId, username, password) {
  return bcrypt.compare(material(userId, username, password), stored);
};

Read-only context: src/auth/bcrypt.js, src/auth/digest.js.

Open the hunt to edit the files, run the visible tests and submit against the hidden ones. More JavaScript bug hunts.