On That Okta LDAP Bug

2024-11-03

A quick explanation of the Okta AD/LDAP DelAuth bug that was being shared around, and the importance of sensible defaults.

So this Okta security advisory was doing the rounds, and was pretty transparent, all things considered:

On October 30, 2024, a vulnerability was internally identified in generating the cache key for AD/LDAP DelAuth. The Bcrypt algorithm was used to generate the cache key where we hash a combined string of userId + username + password. During specific conditions, this could allow users to authenticate by only providing the username with the stored cache key of a previous successful authentication.

bcrypt has a well-known limitation that it has a maximum password length of 72 bytes (or 72 ASCII characters). By using bcrypt as a hash function for a cache key that is composed by concatenation, we can infer that clientId is 20 bytes, and therefore a username of 52 or more bytes results in the password becoming irrelevant.

We can easily demonstrate this in code:

import bcrypt
import secrets
import sys

client_id = secrets.token_hex(10)
client_username = secrets.token_hex(26)
client_password = secrets.token_hex(32)

cache_key = client_id + client_username + client_password

hashed_key = bcrypt.hashpw(cache_key.encode(), bcrypt.gensalt())

check_password = sys.argv[1]
check_key = client_id + client_username + check_password

print("Correct Password: {}".format(client_password))
print("Entered Password: {}".format(check_password))

if bcrypt.checkpw(check_key.encode(), hashed_key):
    print("Password is correct.")
$ python main.py foobar
Correct Password: 3b34021604ace062aa21a77f3461dc0289191988c248467f2401c9540939ec5c
Entered Password: foobar
Password is correct.

Alternatively, you can hash the input with a constant-length hash function, like SHA-256, producing a 64 character hex digest:

@@ -1,4 +1,5 @@
 import bcrypt
+import hashlib
 import secrets
 import sys

@@ -7,14 +8,18 @@ client_username = secrets.token_hex(26)
 client_password = secrets.token_hex(32)

 cache_key = client_id + client_username + client_password
+hashed_input = hashlib.sha256(cache_key.encode()).hexdigest()

-hashed_key = bcrypt.hashpw(cache_key.encode(), bcrypt.gensalt())
+hashed_key = bcrypt.hashpw(hashed_input.encode(), bcrypt.gensalt())

 check_password = sys.argv[1]
 check_key = client_id + client_username + check_password
+hashed_check_input = hashlib.sha256(check_key.encode()).hexdigest()

 print("Correct Password: {}".format(client_password))
 print("Entered Password: {}".format(check_password))

-if bcrypt.checkpw(check_key.encode(), hashed_key):
+if bcrypt.checkpw(hashed_check_input.encode(), hashed_key):
     print("Password is correct.")

Of course, this introduces a possibility of a collision attack, though at the time of writing not feasible. Another problem with this approach is password shucking, as well as a litany of other issues of combining bcrypt with other hash functions. Just use a better hash algorithm designed for password storage.

Interestingly, this was “fixed” by changing the hash function to use PBKDF2 - this is materially worse than bcrypt, but it is FIPS compliant. I can only hope this was implemented with a minimum work factor of 600K and using HMAC-SHA-256 as the internal hash function. My preference would be to use argon2id or scrypt:

from argon2 import PasswordHasher
from argon2.low_level import Type
from argon2.exceptions import VerifyMismatchError
import sys
import secrets

client_id = secrets.token_hex(10)
client_username = secrets.token_hex(26)
client_password = secrets.token_hex(32)

cache_key = client_id + client_username + client_password

ph = PasswordHasher(type=Type.ID)
hashed_key = ph.hash(cache_key)

check_password = sys.argv[1]
check_key = client_id + client_username + check_password

print("Correct Password: {}".format(client_password))
print("Entered Password: {}".format(check_password))

try:
    ph.verify(hashed_key, check_key)
    print("Password is correct.")
except VerifyMismatchError:
    print("Password is incorrect.")
$ python argon2id.py foobar
Correct Password: 720d5d3959f20cbee3f05efc7289f4c307ae7add38c821cfa5708631f4ee77c6
Entered Password: foobar
Password is incorrect

Read this post from Latacora for more “cryptographic right answers”.

The use of bcrypt here with an input that is not well restricted and can easily blow through the known 72-byte limitation is a flaw that should have been caught through design review, but I can understand why - if you don’t have the cryptographic expertise, this is easily missed.

That said, some bcrypt libraries refuse to accept inputs longer than 72 bytes, such as the Go implementation:

package main

import (
	"fmt"

	bcrypt "golang.org/x/crypto/bcrypt"
)

func main() {
	password := []byte("894d8772dcff261840ba07e6ceb271a3803483a1900c2719c76561de735b0dab5318c6895085675d5ee315cf10c0a1a6df4e3bbc7681d2ea502ca4de899b6195de410b20")
	fmt.Println("password_length: ", len(password))

	_, err := bcrypt.GenerateFromPassword(password, bcrypt.DefaultCost)
	if err != nil {
		panic(err)
	}
}
$ go run main.go
password_length:  136
panic: bcrypt: password length exceeds 72 bytes

This reinforces the need to have sensible defaults.

Update 2024-11-03: include SHA-2 hashing as alternative mitigation.