Search code examples
phpencryption

How does PHP 8's password_verify prevent rainbow table attacks when no salt is specified?


Consider this code a web site may use for verifying a password. Note that no salt is specified, as is recommended.

$givenPassword = /*whatever was entered*/;
//look up $username, $encryptedPassword
if (password_verify($givenPassword, $encryptedPassword))
{
   //log user in and do good things
}

Suppose a hacker has stolen the database and wants to know if "secret" is the password for $user.

$givenPassword = "secret";
//look up $username, $encryptedPassword
if (password_verify($givenPassword, $encryptedPassword))
{
   //report so hacker can do evil things
}

The code for encryption is identical. No salt is specified. If I don't specify the salt but PHP does, why doesn't the hacker have access to the same salt with his own PHP interpreter, so that the salt is useless? If the hacker doesn't have access to the salt created by my call to password_hash when he calls password_verify, how does my call to password_verify have access to the right one?

This question is about not about how salts work but how password_verify, not md5 or other older functions with programmer-defined salts, can share the salt with my call to password_verify without it also being available to someone else.


Solution

  • password_verify($givenPassword, $encryptedPassword) does not prevent rainbow table attacks. Such attacks consist on having a precomputed list of $encryptedPassword, so once you put your hands on a leaked database you can do a simple database lookup, and you can also quickly detect all users that happen to be sharing passwords.

    Rainbow table attacks are prevented by password_hash(), which adds a random salt before hashing, what means that you'll never get the same hash twice for the same password:

    var_dump(password_hash('1234', PASSWORD_DEFAULT));
    var_dump(password_hash('1234', PASSWORD_DEFAULT));
    var_dump(password_hash('1234', PASSWORD_DEFAULT));
    
    string(60) "$2y$10$P8ZHgvrmmyyBujjrLQnvR.3iFYcrJqklGr73bckAvx3pVqkKyPj0e"
    string(60) "$2y$10$UbwElT41Jwb6goz6bojEsu.I7ifKXo2yp5lYMJPlT2y8YpofkAuSy"
    string(60) "$2y$10$VK5wC760UwHIaKvpMXxz5upN1AV6SH7iALsJH0wcggX.Dsg4TeUgS"
    

    (If you run this code, you'll also get different hashes than me).

    This renders precomputed tables useless.

    The only role that password_verify($givenPassword, $encryptedPassword) plays is that it's slow. $givenPassword needs to be hashed with the original algorithm and salt used (which are stored in $encryptedPassword), and that's done with specially crafted hashing algorithms specifically designed to be slow. This makes it unfeasible to just start verifying every string combination.

    To have an idea of how slow it can be, you can change the default options:

    $start = microtime(true);
    var_dump(password_hash('1234', PASSWORD_BCRYPT, ['cost' => 17]));
    $end = microtime(true);
    printf("Time: %f seconds\n", $end - $start);
    
    string(60) "$2y$17$17ZLamp.RFlZOjExzbCH0.auz2Lg5cPsZxDl9K.2oCtrR3Uxut0Y2"
    Time: 5.655422 seconds
    
    $start = microtime(true);
    var_dump(password_verify('1234', '$2y$17$17ZLamp.RFlZOjExzbCH0.auz2Lg5cPsZxDl9K.2oCtrR3Uxut0Y2'));
    $end = microtime(true);
    printf("Time: %f seconds\n", $end - $start);
    
    bool(true)
    Time: 5.681215 seconds