phpcryptblowfish

Save this Data: Replicate crypt() behavior with bad salt prior to PHP 8.1.16


Problem: On PHP 8.1.16, the behavior of crypt() changed so that if it is called with certain CRYPT_BLOWFISH incompatible salts, it returns *0 instead of a hashed value. If we upgrade our systems to 8.1.16, we will no longer be able to check entries. Is there a way to replicate the old behavior of crypt() on newer PHP versions? If not, is there a way to be able to make use of the data in the Use Case section below?

Script example:

$salt = '$2a$05$SomeBadSaltHasDollar/$';
$credential = 'my password';

echo crypt($credential, $salt);
// PHP < 8.1.16 output: $2a$05$SomeBadSaltHasDollar/.ry.LY8GXnJWLU9/BIJ5I4VJPRBlp6z.
// PHP >= 8.1.16 output: *0

I need the hashed value from <8.1.16

Use Case:

Consider the following table was generated using a PHP version < 8.1.16

Hash ImportantValue
$2a$05$SomeBadSaltHasDollar/.ry.LY8GXnJWLU9/BIJ5I4VJPRBlp6z. Apple
$2a$05$SomeBadSaltHasDollar/.WguJW8WYSY/4UQb/W/.NJjLxnAQiN12 Orange
$2a$05$SomeBadSaltHasDollar/.q.uf.A8phuGuZAVL3M9NQ3744jT18oW Banana

For inserts into this table:

  1. We would accept a Credential and some Important Value as input
  2. We would use a pre-saved salt (using Blowfish). Unfortunately, this was not a Blowfish compatible salt and we did not realize this at the time (no error was thrown).
  3. We would insert Hash (the output of crypt()) + ImportantValue

For checking entries:

  1. The user would provide their Credential
  2. We would use the same pre-saved salt to generate the hash using crypt()
  3. We would search the database for all records containing that hash: SELECT ImportantValue FROM table WHERE Hash = {$hashedCredential};

The call to crypt() returned what we believed contained the original salt used - in the example above, it appeared that crypt() simply replaced the $ with a ., but this is not the case:

$salt = '$2a$05$SomeBadSaltHasDollar/$';
$credential = 'my password';

$hash = crypt($credential, $salt);
// PHP < 8.1.16 output: $2a$05$SomeBadSaltHasDollar/.ry.LY8GXnJWLU9/BIJ5I4VJPRBlp6z.

$saltPartOfHash = substr($hash, 0, 29);
$newHash = crypt($credential, $saltPartOfHash);
echo $newHash == $hash; // different behavior when using hash containing . instead of $

Solution

  • Alright, let's have a look at the PHP source tree then.

    diff --git a/php-8.1.15/ext/standard/crypt_blowfish.c b/php-8.1.16/ext/standard/crypt_blowfish.c
    index 3806a29..351d403 100644
    --- a/php-8.1.15/ext/standard/crypt_blowfish.c
    +++ b/php-8.1.16/ext/standard/crypt_blowfish.c
    @@ -371,7 +371,6 @@ static const unsigned char BF_atoi64[0x60] = {
     #define BF_safe_atoi64(dst, src) \
     { \
            tmp = (unsigned char)(src); \
    -       if (tmp == '$') break; /* PHP hack */ \
            if ((unsigned int)(tmp -= 0x20) >= 0x60) return -1; \
            tmp = BF_atoi64[tmp]; \
            if (tmp > 63) return -1; \
    @@ -399,13 +398,6 @@ static int BF_decode(BF_word *dst, const char *src, int size)
                    *dptr++ = ((c3 & 0x03) << 6) | c4;
            } while (dptr < end);
     
    -       if (end - dptr == size) {
    -               return -1;
    -       }
    -
    -       while (dptr < end) /* PHP hack */
    -               *dptr++ = 0;
    -
            return 0;
     }
    

    Previously, the salt decoding routine would bail out early upon encountering a $ and fill the rest of the output buffer with zeroes, now it will just error out.

    A . in the input salt will get translated to 6 bits of zeroes, so that can be used to achieve the same result with crypt(), but it needs a bit more work. Due to the way input characters are consumed, it can actually affect the character before the $ as well. It all depends on the offset of the $ character within the 22-character part at the end. To make this dependency most visible, we can use a couple of 9s in front of the dollar, since 9 gets translated to 63 (0x3f):

    $credential = 'my password';
    
    echo crypt($credential, '$2a$05$SomeDollarSalt9999999$')."\n";
    echo crypt($credential, '$2a$05$SomeDollarSalt999999..')."\n\n";
    
    echo crypt($credential, '$2a$05$SomeDollarSalt999999$9')."\n";
    echo crypt($credential, '$2a$05$SomeDollarSalt999999..')."\n\n";
    
    echo crypt($credential, '$2a$05$SomeDollarSalt99999$99')."\n";
    echo crypt($credential, '$2a$05$SomeDollarSalt99996...')."\n\n";
    
    echo crypt($credential, '$2a$05$SomeDollarSalt9999$999')."\n";
    echo crypt($credential, '$2a$05$SomeDollarSalt999u....')."\n\n";
    

    In PHP versions before 8.1.16, this prints:

    $2a$05$SomeDollarSalt9999999.hm2CRfKZ6A8x3vd6BRDTNlN3DDJPW3e
    $2a$05$SomeDollarSalt999999..hm2CRfKZ6A8x3vd6BRDTNlN3DDJPW3e
    
    $2a$05$SomeDollarSalt999999$uhm2CRfKZ6A8x3vd6BRDTNlN3DDJPW3e
    $2a$05$SomeDollarSalt999999..hm2CRfKZ6A8x3vd6BRDTNlN3DDJPW3e
    
    $2a$05$SomeDollarSalt99999$9uGElX9NYrj.R45tRnH.xGf936EuwfQ8W
    $2a$05$SomeDollarSalt99996...GElX9NYrj.R45tRnH.xGf936EuwfQ8W
    
    $2a$05$SomeDollarSalt9999$99uqedqg9AH7TRnGTjNVhwYkmNJOSW5DpC
    $2a$05$SomeDollarSalt999u....qedqg9AH7TRnGTjNVhwYkmNJOSW5DpC
    

    The prepended salt value is different so we'll have to manually fix that up later, but the actual computation result is the same already. Note how in the first two cases, the exact same characters in the sale have to be replaced with dots, despite the fact that the dollar sign is in a different place. And how in the latter two cases, we have to turn a 9 into a 6 and a u respectively. That is because the early break leaves some previous input bits unconsumed. Specifically, the input characters are turned into their "index" in the "itoa" string ./ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789, and in the case of an early break, only the bits 0x30 or 0x3c get consumed, respectively. To fix that, we first mimic the same lookup with a simple strpos(), apply the same bitmask, then transform the value back to a character by just indexing into the string.

    Now we have out recomputed salt ready to go, but as we saw above, the salt value gets prepended to the output verbatim. Or almost verbatim, because you already noticed that your $ gets transformed to a .. But only if it's the very last character, otherwise it's preserved. But the last character gets transformed no matter what, with out 9s all turning into us. Exact same concept here, 22 is not divisible by 4, so we end up with some bits in the salt that don't get consumed, which we can fix up with the same "itoa" string indexing we did above.

    Equipped with this knowledge, we can build a legacy salt compatibility layer:

    function BF_crypt_with_legacy_salt($string, $salt)
    {
        // PHP <=8.1.15 compat: check for $2a$ blowfish salt with dollar
        // If this confuses you, go diff ext/standard/crypt_blowfish.c between PHP 8.1.15 and 8.1.16
        if(strlen($salt) == 29 && substr($salt, 0, 4) == '$2a$' && $salt[6] == '$')
        {
            $pos = strpos($salt, '$', 7);
            if($pos !== false)
            {
                $pos -= 7;
                $itoa = './ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
                // Transform provided salt into something that matches the decoding from PHP 8.1.15
                if(($pos & 0x2) == 0)
                {
                    // Offsets 0 and 1: truncate down to even length before the $
                    $cryptsalt = substr($salt, 0, 7 + ($pos & 0x1e));
                }
                else
                {
                    // Offsets 2 and 3: truncate to two before the $, and transform the char before the $
                    $val = strpos($itoa, $salt[7 + ($pos - 1)]);
                    if($val === false)
                    {
                        return '*0';
                    }
                    $cryptsalt = substr($salt, 0, 7 + ($pos - 1)).$itoa[$val & (($pos & 0x1) == 0 ? 0x30 : 0x3c)];
                }
                for($i = 29 - strlen($cryptsalt); $i > 0; --$i)
                {
                    $cryptsalt .= '.';
                }
                // But we'll actually need a different salt value to prepend to the output
                $last = strpos($itoa, $salt[28]);
                $textsalt = substr($salt, 0, 28).$itoa[$last === false ? 0 : ($last & 0x30)];
                // Do the actual crypt() call
                $crypt = crypt($string, $cryptsalt);
                // Restore original salt in output
                if(substr($crypt, 0, 29) == $cryptsalt)
                {
                    $crypt = $textsalt.substr($crypt, 29);
                }
                return $crypt;
            }
        }
        // Otherwise just pass through unchanged
        return crypt($string, $salt);
    }
    

    You can see the results of running that against the same input as above in this 3v4l snippet.