If you want to learn how to store passwords securely, you could do a lot worse than looking at the OWASP Password Storage Cheat Sheet. These cheat sheets are generally pretty good, and the password storage one is particularly good. The editors do a great job of keeping it up to date and incorporating the latest research from experts. (Just bear in mind that the recommendations there are when using password for authentication. If you’re using a password to encrypt sensitive data then you should be aware of some limitations).
One of the hash functions that OWASP recommend is bcrypt, which should be familiar to anyone who’s ever looked at password hashing. Bcrypt is generally an ok choice, but it has some quirks that make it hard to love. As pointed out in the cheat sheet, many implementations cannot handle input passwords longer than 72 bytes. (And some implementations are not binary safe either). To get around this, it was common advice at one point to “pre-hash” the input using some other fast hash function like SHA-256. That is, rather than the stored password hash being bcrypt(password)
it was bcrypt(sha256(password))
or something similar. This was also sometimes done when an old insecure password database using something like unsalted MD5 was upgraded by simply re-hashing the existing hashes with bcrypt: md5(password) -> bcrypt(md5(password))
.
On the face of it, this seems like a reasonable and safe thing to do. After all, if someone gets a copy of your password database they will be faced with a list of hard-to-crack bcrypt hashes, rather than raw unsalted MD5 or SHA-1 or whatever.
Enter hash shucking
This is where “hash shucking” (or “password shucking”) enters the picture. Imagine that an attacker compromises your database and gets hold of this list of bcrypt(md5(password))
password hashes. Now imagine that the attacker already has a bunch of unsalted MD5 hashes from a previous breach, that for whatever reason they haven’t cracked yet. The idea of hash shucking is that an attacker can take these MD5 hashes and run them through bcrypt (with the same salts and cost factors from your database) and see if any match any of the hashes in your database. If they do, then they know that those unsalted MD5 hashes correspond to passwords used by your users. They can then spend their time cracking those MD5 hashes to recover the passwords, apparently bypassing needing to attack bcrypt at all.
When I first learned of this attack, my reaction was that the problem here is those unsalted MD5 hashes from the other breach. If they are crackable, then an attacker is going to crack them anyway, at which point they can just try those passwords anyway. So what does shucking really buy the attacker here? The way it was (very patiently) explained to me, is to imagine a high-value target that is using a quite good password. They have reused it on multiple sites, but it’s something pretty hard to guess like a random-ish 10 character password that isn’t in any existing password breach databases. Hard to crack, but it’s not uncrackable.
The thought then is that the attacker may have exhausted basic dictionary attacks on the unsalted MD5s and failed to crack this password. With password shucking though, they learn that this password is being used by a high value target on a juicy website. So now they know that, they will concentrate their resources on trying to crack this particular hash using more sophisticated and expensive techniques such as brute-force enumerating every 10-character password. With this concentrated firepower, they will eventually crack this unsalted MD5, something they could never achieve against bcrypt.
But this isn’t really how cracking unsalted password hashes goes. You don’t need to “concentrate” resources on a few candidate hashes. The whole point of unsalted hashes (and the reason we use salts in the first place) is that you can attack them all at once for roughly the same cost as attacking one: you generate a candidate hash and then see if it matches any in your database. This check can be performed in constant time regardless of the number of hashes to check, or close enough to not matter. Heck, go wild and build a Rainbow Table (they are really fun). You don’t need to attack them one at a time.
Now maybe I’m still missing some fundamental reason why this attack makes sense, but at the moment I’m struggling to see it. If your password ends up in an unsalted MD5/SHA-1 breach and the password is crackable then it’s best to assume that it is going to be cracked. I don’t see how hash shucking changes the equation there at all. Now, perhaps through this technique an attacker learns that some MD5 hash is also the password of some high-value target, but they could probably already figure this out from the email address or other metadata associated with that hash.
(Which is not to say hash shucking is a bad idea or poor research: it’s an interesting idea regardless).
Domain separation
Regardless of whether you think hash shucking is a real attack or not, one thing to notice is that it wouldn’t be an issue at all if you used some really unusual (but still secure) hash function to pre-hash the input to bcrypt. It’s pretty unlikely that anyone is using say BLAKE3-256 for unsalted password hashes, because anyone who knows that BLAKE3 even exists likely also knows not to use an unsalted password hash. (Well, ok that is maybe a shaky assumption). So it’s very unlikely that there is a breached database of unsalted BLAKE3 hashes sitting around out there for an attacker to try against your bcrypt database.
This may all sound a bit “security through obscurity” and that I’m going to suggest using some crazy homespun hash function. But there is a more principled way to think about this. A hash function approximates an ideal object known as a “random oracle”. Often in security proofs we need to assume that different uses of a hash function in some code behave like independent random oracles: that is, they produce different outputs even when given the same input. One way to do this is via a process known as domain separation: we ensure that the inputs to the hash function are always different so that the output is always different (with high probability). There are a bunch of ways to do this, for example we can just add a fixed prefix string to the hash input for each usage. For example, we could prefix the inputs to our bcrypt pre-hash with the string “bcrypt-prehash”:
bcrypt(md5(“bcrypt-prehash” + password))
A more principled way to do this is via dedicated constructions that support a separate salt argument (hash salting is itself a form of domain separation), such as HDKF-Extract, where the salt is used as a HMAC key, making the combined construction look like:
bcrypt(hmac-md5(key: “bcrypt-prehash”, data: password))
More modern hash functions like cSHAKE or BLAKE3 support “customisation strings” that natively support this kind of domain separation without needing to use HMAC.
Now if you go and look at what people are recommending to protect against hash shucking, it is something exactly like this. The first suggestions were to use a separate random salt per user, and then I saw suggestions to use a random “pepper” that is the same for all passwords, and I’ve now also seen people suggest that a simple constant string like “bcrypt-prehash” is fine. And it is.
So even though I don’t believe that hash shucking is a real issue, the solutions that are proposed to counter it are perfectly sensible and good practice. By all means, if you are going to prehash bcrypt password hashes then add some domain separation. At the very least, if you ever want a cryptographer to prove your construction safe then they will thank you for it.
(Just remember, that if you are prehashing for bcrypt, then make sure the output is hex-encoded or similar and less than 72 bytes to avoid common issues. Really, though, just use a better password hash without these legacy quirks).
Summary
I still don’t understand why hash shucking is an issue. From my perspective it doesn’t give the attacker any advantage that they didn’t already have. On the other hand, the solutions proposed to eliminate hash shucking are sensible things to do anyway, so they are worth doing regardless of whether you think it’s a real issue or not. But consider using a more modern hash function that doesn’t have the weird limitations of bcrypt.
(And as always, use a password manager, use 2FA, use Passkeys, sign up for my newsletter, etc etc).