當Bcrypt與其他Hash函式同時使用時造成的安全問題

wyzsk發表於2020-08-19
作者: phith0n · 2015/03/17 16:06

0x00 前言


php在5.5版本中引入了以bcrypt為基礎的雜湊函式password_hash,和其對應的驗證函式password_verify,讓開發者輕鬆實現加鹽的安全密碼。本文就是圍繞著password_hash函式講述作者發現的安全問題。

有一次,我在StackOverflow上看到個有趣的問題:當密碼位數過長的時候,password_verify()函式是否會被DOS攻擊?很多雜湊演算法的速度都受到資料的量的影響,這將導致DOS攻擊:攻擊者可以傳入一個非常長的密碼,來消耗伺服器資源。這個問題對於Bcrypt和PasswordHash也很有意義。我們都知道,Bcrypt限制密碼的長度在72個字元以內,所以在這點上它不會被影響的。但我選擇進行深入挖掘的時候,卻發現了其他令我驚訝的問題。

0x01 crypt.c分析


首先我們看看php是怎麼實現crypt()函式的,這個我們感興趣的函式在原始碼中對應“php_crypt()”,宣告如下:

#!c
PHPAPI zend_string *php_crypt(const char *password, const int pass_len, const char *salt, int salt_len)

我們看看進行加密的部分

#!c
} else if (
        salt[0] == '$' &&
        salt[1] == '2' &&
        salt[3] == '$') {
    char output[PHP_MAX_SALT_LEN + 1];

    memset(output, 0, PHP_MAX_SALT_LEN + 1);

    crypt_res = php_crypt_blowfish_rn(password, salt, output, sizeof(output));
    if (!crypt_res) {
        ZEND_SECURE_ZERO(output, PHP_MAX_SALT_LEN + 1);
        return NULL;
    } else {
        result = zend_string_init(output, strlen(output), 0);
        ZEND_SECURE_ZERO(output, PHP_MAX_SALT_LEN + 1);
        return result;
    }
}

注意到了嗎?由於password變數一個是char指標(char *),所以php_crypt_blowfish_rn()並不能知道引數password的長度。我想看看他是怎麼獲得長度的。

跟進php_crypt_blowfish_rn(),我發現唯一使用password變數(函式里叫key)的地方,是把它傳給了 BF_set_key()函式。這個函式的註釋中說明了一些設定和安全使用事項,實際上總結起來就是下面這個迴圈(去除了註釋):

#!c
const char *ptr = key;
/* ...snip... */
for (i = 0; i < BF_N + 2; i++) {
    tmp[0] = tmp[1] = 0;
    for (j = 0; j < 4; j++) {
        tmp[0] <<= 8;
        tmp[0] |= (unsigned char)*ptr; /* correct */
        tmp[1] <<= 8;
        tmp[1] |= (BF_word_signed)(signed char)*ptr; /* bug */
        if (j)
            sign |= tmp[1] & 0x80;
        if (!*ptr)
            ptr = key;
        else
            ptr++;
    }
    diff |= tmp[0] ^ tmp[1]; /* Non-zero on any differences */

    expanded[i] = tmp[bug];
    initial[i] = BF_init_state.P[i] ^ tmp[bug];
}

給不懂C語言的人:用來解引用指標,也就是返回這個指標指向的值。所以我們定義char *abc = ”abc”,那麼abc的值就是’a’(事實上是’a’的ascii碼值)。當你執行了abc++以後*abc的值就等於’b’。這就是C語言中字串string的工作原理。

接著看,這個迴圈會迭代72次(因為BF_N等於16),每次迭代會“吃掉”字串的一個字元。

那麼看以下程式碼:

#!c
if (!*ptr)
    ptr = key;
else
    ptr++;

如果*ptr的值為0,那麼重新讓它指向字串的首字元,依此規則迴圈,執行72次。這就是為什麼傳入的字串要小於72個字元(因為C語言字串是以NUL結尾,所以要佔用一個位元組)。

那我們來想想,以上程式碼意味著”test\0abc”將會被處理成”test\0test\0test\0test\0test\0test\0test\0test\0test\0test\0test\0test\0test\0test\0te”。實際上,所有以”test\0”開頭的字串都會被處理成這樣。

結果就是,它忽略了第一個NUL以後的所有內容(test\0abc中的abc)。

這樣會產生什麼問題呢?很明顯,你的密碼變短了(從test\0abc變成test\0)。但因為沒有人會在密碼中使用“\0”,那麼它是不是不算問題了?

事實上,的確沒人會在密碼中使用“\0”,所以如果你單獨使用password_hash()或crypt()的時候,你是100%安全的。 但如果你不是直接使用他們,而是進行了“預雜湊”(pre-hash),那你就會遇到本文中說的主要問題。

0x02 主要問題


有些同學覺得單獨使用bcrypt不夠,而是選擇去”預雜湊(pre-hash)”,也就是預先計算一次雜湊,再把返回結果傳入正式的雜湊函式進行計算。

這樣可以讓使用者去使用“更長”的密碼(超過72個字元),如:

#!c
password_hash(hash('sha256', $password, true), PASSWORD_DEFAULT)

另外,也有些同學想給雜湊加點“salt”,所以配合私鑰使用HMAC:

#!c
password_hash(hash_hmac('sha256', $password, $key, true), PASSWORD_DEFAULT)

問題在於,以上用法中hash和hash_hmac函式的最後一個引數傳入的都是true,它強制函式返回原始(二進位制)資料。使用原始資料而非編碼後的資料,再次計算雜湊,這種做法在加密函式中是很常見的。這樣做可以在你把sha512加密後128位的資料截斷成72位而失去熵的同時,也還能多留一些熵。

但這意味著第一次雜湊函式輸出的內容中,可以含有“\0”。而且有高達近1/256(0.39%)的可能性第一位是”\0”(這時候你的密碼等於變成了一個空字串)。那麼我們只需要去嘗試大概177次密碼,就有50%的機會獲得一個第一位是NULL字元的密碼,等於大概177個使用者就有50%的機率使用了一個NULL開頭的密碼。所以我們嘗試31329(177 * 177)個賬號和密碼的組合就有25%的機率成功登入一個賬戶。這給線上碰撞雜湊提供了可能(如:透過分散式的方式)。

這真是糟糕透了。

我們來看一個利用上述方法碰撞賬號密碼的例子:

#!c
$key = "algjhsdiouahwergoiuawhgiouaehnrgzdfgb23523";
$hash_function = "sha256";
$i = 0;
$found = [];

while (count($found) < 2) {
    $pw = base64_encode(str_repeat($i, 5));
    $hash = hash_hmac($hash_function, $pw, $key, true);
    if ($hash[0] === "\0") {
        $found[] = $pw;
    }
    $i++;
}

var_dump($i, $found);

我選擇了一個隨機的$key,然後我用一個看似“隨機”的密碼$pw(其實是5個重複字元進行base64編碼後的值),然後開始跑。這段傻傻的程式碼開始進行碰撞(效率比較低)。最後獲得瞭如下結果:

#!c
int(523)
array(2) {
  [0]=>
  string(16) "MzEzMTMxMzEzMQ=="
  [1]=>
  string(20) "NTIyNTIyNTIyNTIyNTIy"
}

我們在523次嘗試中碰撞出了2個密碼“MzEzMTMxMzEzMQ==”和“NTIyNTIyNTIyNTIyNTIy”,嘗試的次數將會隨著金鑰的改變而改變。 然後我們做以下實驗:

#!c
$hash = password_hash(hash_hmac("sha256", $found[0], $key, true), PASSWORD_BCRYPT);
var_dump(password_verify(hash_hmac("sha256", $found[1], $key, true), $hash));

得到如下輸出:

#!c
bool(true)

十分有趣。兩個不同的密碼卻被認為是同一個hash,我們的雜湊碰撞奏效了。

0x03 檢測有問題的雜湊值


我們可以用如下方法簡單地測試我們的雜湊值是否是NULL字元開頭的:

#!c
password_verify("\0", $hash)

比如,我們測試下面的雜湊:

#!c
$2y$10$2ECy/U3F/NSvAjMcuBeI6uMDmJlI8t8ux0pXOAoajpv2hSH0veOMi

返回結果為bool(true),說明它是由首字元為NULL的字串加密得到的。

所以,在離線情況下,只用一行的程式碼即可檢測這個問題。

另外,就算你計算二次雜湊值的字串不是以NULL字元開頭,也不代表你絕對的安全(假設你使用了上述有缺陷的加密演算法)。當第二個字元是NUL的時候,同樣的事情也可能發生:

a\0bc
a\0cd
a\0ef

這些都是可以碰撞的,你將會有0.39%的機率碰撞到第二個字元是\0的結果。在所有首字母為a的字串中,你也將有0.39%的機率獲得一個第二個字元是\0的結果。這意味著我們的破解密碼的工作量從碰撞整個字串雜湊變成了只用碰撞以上很短的字串。 這個問題將一直延續下去(第3個字元是\0、第4個、第5個……)。

有些人說我並未使用password_hash,我用了CRYPT_SHA256!

看到原始碼中的php_crypt()函式,我們可以發現crypt()中所有加密方式都有這樣的行為,它並不僅存在於bcrypt,也不僅集中於php,整個crypt(3) C語言庫都有這個問題。

我在文中主要使用bcrypt來說明問題的原因是password_hash()呼叫了它,而password_hash是當前PHP推薦的加密演算法。

值得注意的是,如果你使用了hash_pbkdf2(),就不容易被影響,而使用的是scrypt庫會更好。

0x04 修復方法


這個問題不是出在bcrypt,而是同時使用了bcrypt與其他加密方式造成的。事實證明,也並不是所有組合都是不安全的。《Mozilla's system》裡提到的方式password_hash(base64_encode(hash_hmac("sha512", $password, $key, true)), PASSWORD_BCRYPT) 是安全的,因為它在獲得了hash_hmac的返回值後進行了base64編碼。另外,如果hash/hmac返回值是hex形式的話你也是安全的(最後一個引數是預設的false)。

如果你按以下說明去做,你就是100%安全的:

  • 1.直接使用bcrypt加密(而不去pre_hash)
  • 2.使用hex形式的值作為pre_hash的引數
  • 3.使用base64編碼後的值作為pre_hash的引數

總之,要不就不要pre_hash,要不就編碼後再進行pre_hash。

0x04 根本問題


根本問題就是加密演算法最初就不是為同時使用而設計的。同時使用多個加密演算法會讓開發者覺得安全,但實際上並不是,上述問題只是這種錯誤做法的一個體現。

所以,我們應該按照演算法設計者預想的方式去使用他們。如果你想在bcrypt上再加強防禦,那就加密它的輸出結果:

#!c
encrypt(password_hash(...), $key)。

最後,也是最最重要的是:絕不要發明自己的加密演算法,否則會導致致命後果。

原文:http://blog.ircmaxell.com/2015/03/security-issue-combining-bcrypt-with.html?m=1

本文章來源於烏雲知識庫,此映象為了方便大家學習研究,文章版權歸烏雲知識庫!

相關文章