PHP 5.3.7 Broke crypt(). Go Straight to 5.3.8
PHP 5.3.7 came out on August 18 with more than 90 bug fixes, several of them security fixes. It was the kind of release you’d normally want on your servers quickly. Four days later php.net posted a short news item telling people to hold off on upgrading until 5.3.8, and 5.3.8 followed on August 23. If you’re still on 5.3.6, skip 5.3.7 entirely. If you already upgraded, read on, because the bug was in the function a lot of login code leans on.
What broke
The problem is bug #55439, titled “crypt() returns only the salt for MD5”. Hand crypt() an MD5 salt (the ones starting with $1$) and instead of the full 34 character hash you get back just the salt part. The example in the report asks for the hash of password with the salt $1$U7AjYB.O$ and gets $1$U7AjYB.O back. DES and Blowfish salts behaved normally, according to the reporter.
The interesting detail is the timeline. The report was filed on August 17 against a 5.3.7 release candidate, the day before the final release. It was confirmed against the released 5.3.7 on the 19th, and the fix landed in SVN the same day. The developer who fixed it pointed out that the unit tests were already failing on SVN. That happens, which is the whole point of this post.
Think about what salt-only output does to a login system. When a user signs in, the usual pattern is to run crypt($password, $storedHash) and compare the result to $storedHash. On 5.3.7 the result is only the salt, so it never matches an existing MD5 hash, and your users with MD5 hashes can’t log in. It gets much worse for anyone who set or changed a password while 5.3.7 was running. What got saved is just the salt, which contains nothing about the password at all, and on 5.3.7 running crypt() on any password with that stored value returns the same salt right back. The comparison passes, so those accounts accept any password at all for as long as you stay on 5.3.7. That’s not a lockout, it’s an open door. After you upgrade, look for stored $1$ hashes that aren’t 34 characters long, force a password reset on those accounts, and check your logs for logins to them during the window you were on 5.3.7.
The 5.3.8 announcement lists exactly two fixes, both for problems introduced in 5.3.7: the crypt() bug, and a revert of a timeout handling change that made mysqlnd SSL connections hang. That’s a pleasantly small diff to deploy.
The Blowfish change is separate, and it’s a good one
5.3.7 also carried a real security fix that’s easy to confuse with the regression. According to the 5.3.7 changelog, PHP updated its bundled crypt_blowfish code to version 1.2 for CVE-2011-2483. The bug there: crypt_blowfish before 1.1 mishandled characters with the 8th bit set, so passwords containing non-ASCII characters could be hashed in a weaker way than intended. PHP’s own write-up says that before 5.3.7 the $2a$ prefix behaved differently depending on the platform for those passwords, and on most Linux installs it got them wrong in a way that weakened security.
The fix came with a new prefix. The crypt_blowfish page at Openwall says version 1.2 adds $2y$ for correctly computed hashes, plus a countermeasure on $2a$ against collisions between a correctly computed hash and many old buggy ones. In PHP 5.3.7 that works out like this: $2y$ is the fully correct behavior and a natural pick for new hashes (PHP’s notes say $2a$ is fine in practice too), $2a$ is almost correct with that countermeasure added, and $2x$ deliberately reproduces the old buggy behavior, so an admin who needs old hashes of 8-bit passwords to keep working can switch those to $2x$ and accept the risk until those users change their passwords. If your users’ passwords are plain ASCII, all three prefixes produce the same result and you don’t need to do anything.
The boring lesson
I don’t think the takeaway is “PHP releases are bad.” The fix was fast and the warning was clear. The takeaway is that a point release can still break something you care about, and the cost of waiting a few days before putting a fresh release on production is usually tiny compared to the cost of locking everyone out.
So give new releases a few days and watch the bug tracker and the php.net news before you roll out. And after any PHP upgrade, test authentication on staging before anything else: log in with an existing account, set a new password, and log in with that too. For the MD5 case, a tiny smoke test you can run from the command line on each box does the trick:
<?php
$hash = crypt('password', '$1$U7AjYB.O$');
$ok = $hash === '$1$U7AjYB.O$L1N7ux7twaMIMw0En8UUR1'
&& crypt('wrong', $hash) !== $hash;
echo $ok ? "crypt ok\n" : "crypt BROKEN\n";
If that prints “crypt BROKEN”, don’t ship it. And if you’re on 5.3.7 right now, go to 5.3.8 today.