天天看點

PHP會話安全

原文:https://paragonie.com/blog/2015/04/secure-authentication-php-with-long-term-persistence

在web開發中的一個常見問題是實作使用者身份驗證和通路控制,通常是通過注冊和登入的形式來完成。盡管這些系統在理論上很簡單,實作一個符合應用安全标準的系統是一項艱巨的任務。

沒有大量的仔細和謹慎,認證系統可以像一個紙闆檸檬水攤子在五級飓風下那樣脆弱。然而,對于一切可能出的錯,的确有一個有效(通常簡單)的方式來實作更高水準的安全和彈性。

概要

1. 2015年密碼安全存放方式

2. 正确實作持久認證(“記住我”選框和長期的cookies)

3. 賬戶恢複(“找回秘密”)

密碼:HASH, SALTS 和Policies

那年是2004年。MD5會産生碰撞的消息傳遍了大街小巷,說這個雜湊演算法必然會死亡雲雲。在那5年以前,Niels Provos在USENIX99上釋出了bcrypt。PBKDF2的草案也公布了4年了。

你能相信2015的現在,Web開發人員仍然在儲存密碼時使用類似MD5和SHA1之類的快速雜湊演算法嗎?安全專家很多年以前就确定這是個壞主意了。

合适的密碼儲存系統

現在隻有4種密碼雜湊演算法是被專業密碼學家和安全研究人員認為是安全可靠的。

* Argon2 (密碼哈希競賽的赢家)

* bcrypt

* scrypt

* PBKDF2 (Password-Based Key Derivation Function #2 基于密碼的密鑰導出函數)

對于許多不能在Production環境下安裝PECL的PHP開發人員來說,scrypt不在考慮範圍内。如果你可以使用的話,請一定使用它。

在bcrypt和PBKDF2中選一個的話,應該選bcrypt。再者,應該使用現有的 password_hash() 和 password_verify() 函數而不是自己在 crypt() 的基礎上自己再實作一遍。

bcrypt的局限性

開發人員應該記住bcrypt有兩個缺點:它會将密碼砍短到72個字長,NUL位元組也一樣被砍掉。很多開發人員嘗試通過先HASH一次密碼來解決這個72字長的限制,但是這樣會觸發第二個問題。下面這段代碼就很危險:

$stored = password_hash(hash('sha256', $_POST['password'], true), PASSWORD_DEFAULT);// ...
if (password_verify(hash('sha256', $_POST['password'], true), $stored)) {       // Success :D
} else {    
    // Failure :(
}      

HASH結果裡面有一定的幾率是存在有0x00的位元組。這個位元組出現得越早,碰撞的幾率就呈指數倍的增長。例如,1]W和@1$用SHA-256處理之後都以ab00開頭。

解決方法是,将SHA-256的結果再base64_encode()一遍,之後再傳給bcrypt:

$stored = password_hash(
    base64_encode(
        hash('sha256', $_POST['password'], true)
    ),    PASSWORD_DEFAULT);// ...if (password_verify(
    base64_encode(
        hash('sha256', $_POST['password'], true)
    ),    $stored)) {    // Success :D} else {    // Failure :(
}      

上面的例子不會将72字長以外的數值丢掉,而且是完全的二進制安全。是以早出現的null位元組不會導緻安全問題。兩個問題都解決了。

要不要加胡椒?

有時候,開發人員想在原本的基礎上增加一些難度。加胡椒來增加蠻力攻擊的難度這個話題(PHP知道而資料庫不知道的秘鑰)在程式猿論壇裡面讨論得相當頻繁。就拿上面的例子來講,加胡椒就是将

hash('sha256', $_POST['password'], true)替換成

hash_hmac('sha256', $_POST['password'], CONSTANT_SECRET_KEY, true)

. 但是我們并不推薦這種方式。

胡椒并沒有在password_hash()加salt之後生成給你的東西增加有益的安全輔助。如果你的資料庫和Web應用在同一台機器上,一個能通路你的資料庫的攻擊者很可能離通路你的PHP代碼不遠了。最後,依賴一個靜态的HMAC秘鑰意味着永遠不能輕易的改變這個秘鑰,除非重設使用者密碼。

一個更好的而且非常有用的解決方法是,如果你采用的是資料庫和代碼分離在不同的機器的話,将hash的結果加密之後再存進資料庫。

用這種方式的話,就算攻擊者下載下傳了你所有的資料,他們也得先解密得出你的hashes值,才能夠嘗試破解密碼。代碼和資料庫分離的情況下,這種方法就非常的安全。

加密hash的有點在于,你可以解密然後用新的密碼再加密存起來,而不必擔心原本的值。

然而,話雖如此,請不要建立自己的加密庫。我們牆裂推薦Defuse Security's PHP 加密庫。

最後,我們的團隊寫了一個叫做PasswordLock的庫,裡面實作了上面所提到的Bcrypt-SHA2-Base64方法,并且用我們推薦的加密算法将結果資料包裝了起來。舉個栗子:

use \ParagonIE\PasswordLock\PasswordLock;

define('PASSWORD_KEY', \hex2bin('0102030405060708090a0b0c0d0e0f10'));

// Even better: use a configuration file stored outside your document root
// and not checked into version control
$store_me = PasswordLock::hashAndEncrypt($_POST['password'], PASSWORD_KEY);
if (PasswordLock::decryptAndVerify($_POST['password'], $store_me, PASSWORD_KEY)) {    
    // Success! :D
} else {    
    // Failure :(
}      

密碼政策

誰需要他們

密碼政策(特别是那些麻煩的條件)通常是沒有采用合适的密碼儲存方式的死贈品。通常最好的密碼政策是一開始就沒有政策。

建立最低的要求沒有關系(例如至少12個字段長),但是限制哪些字母不可以或者要求強制最大密碼長度就不是了。通常來說,密碼政策不應該強制最大長度,隻需要強制最小長度。

一個給使用者提供密碼強度回報的好方法是使用Dropbox的zxcvbn庫。

特别要贊一下那些讓使用者知道密碼管理工具的優點的Web應用(例如KeePass或者KeePassX).

合理的密碼政策例子:

1. 密碼長度必須在12~4096之間

2. 密碼可以包含任何的字元(包括unicode)

3. 我們牆裂推薦使用像KeePass或者KeePassX這樣的密碼管理工具來生成和儲存密碼。

4. 你的zxcvbn密碼強度必須在level 3以上 (一共4個等級)

這樣就夠了。不要告訴限制使用者的密碼内容。不要拒絕長密碼。但是如果使用者準備幹蠢事的時候一定要阻止他們(例如使用密碼1234567),但是除此之外不要做過多的幹預。

“記住我” - 持久認證

短期的使用者認證典型采用的是sessions,長期的認證就要依賴于一個不同于session id的長效的cookie。通常使用者通過一個“在這台機器上記住我”的選框體驗到這個功能。實作一個“記住我”的特性而不依賴于繁瑣的開發需要一點點的技巧。

天真的解決方案:把賬戶資訊存到cookie

任何像remember_user=1337這樣的解決方案都會為濫用打開友善之門。因為通常管理猿賬号都有一個很小的ID号,例如remember_user=1就能夠順利的冒充頂級使用者登陸到系統中去。

持久認證的tokens

另外一個普遍使用的政策,更不容易受到攻擊的方式,就是為記住使用者生成一個唯一的token,将這個token儲存在一個cookie裡,然後在資料庫裡将這個token和使用者對應起來。仍要會有一些可能出錯的地方,但是這相對于前一種解決方案來說毫無疑問是個重大的改進。

問題1:沒有足夠的随機産生

雖然很多開發人員明白不可預測性對于安全的token來說的重要性,但是很多人并不知道怎麼去達成這個目标。下面舉一個不大常見的生成唯一token的代碼:

function generateInsecureToken($length = 20){    
    $buf = '';    
    for ($i = 0; $i < $length; ++$i) {
        $buf .= chr(mt_rand(0, 255));
    }
    return bin2hex($buf);
}      

mt_rand() 函數并不适合安全需求。如果你需要生成一個随機數值,你可以用以下的方法:

* RandomLib

* random_bytes($length) 

* 從/dev/urandom裡讀出未加工的位元組

* mcrypt_create_iv($length, MCRYPT_DEV_URANDOM)

* openssl_random_pseudo_bytes($length)

正确的姿勢是:

function generateToken($length = 20){
    return bin2hex(random_bytes($length));
}      

問題2:計時洩露

即使你使用了加密安全的随機數生成方式,你的cookie看起來像這個樣子:

rememberme=WBWgm2oMFxsiGRGQNJ6n8gtN3gOuQ2wjN8ZRjZtU0Mn,然後你在資料庫裡面這樣儲存和查詢:

CREATE TABLE `auth_tokens` (
    `id` integer(11) not null UNSIGNED AUTO_INCREMENT,
    `token` char(33),
    `userid` integer(11) not null UNSIGNED,
    `expires` integer(11), -- or datetime
    PRIMARY KEY (`id`)
);      
SELECT * FROM auth_tokens WHERE token = 'WBWgm2oMFxsiGRGQNJ6n8gtN3gOuQ2wjN8ZRjZtU0Mn';      

注意了,一個深奧不平凡的攻擊仍然存在。(接下來這段譯者盡量不直譯試圖将原文的意思表達清楚)

這樣第一眼看上去沒有什麼不妥,但是這樣洩露了時間資訊,怎麼說?

首先談到一種攻擊,叫做遠端計時攻擊。這是通過反複嘗試得出處理時間資訊進而推斷出密碼的一種方式。舉個例子,當我們不知道要傳進來的字母是什麼的時候,我們會将這個字元的變量與"abcdefghijklmnopqrstuvwxyzABCDEFGHIJ...."逐位比對,比對後馬上傳回,這樣導緻的結果是啥呢?如果我将每一個字元傳進去,得到一個時間清單,那麼當傳進一個我們不知道是什麼的字元的時候,隻需要通過之前的表就可以回推傳進去的字元。因為得到一個比對的時間跟它在上面字串的位置有關,越往後需要的時間越長。

把這個邏輯應用到密碼上面來是什麼一種情況呢?假如,目标密碼是'test123',那麼如果第一位就錯的情況下,程式傳回就是最快的,那麼我們隻要建構一系列的axxxxx, bxxxxx...挑出時間最長的那個,便可以知道第一位字母是什麼,如此類推。當然,時間長短需要用統計均值來表示,否則單次的結果并不能有決定性的指向。

回過頭來看看上面定長的token,是不是在理想狀态下也會受到同樣的攻擊。不過在硬體越來越發達的現在,這種攻擊的難度也是挺大的。不過既然是關心安全問題,那麼置之不理留下漏洞也不是我們所追求的。譯者認為這個問題有個簡單的解決方案,便是将token再hash一遍存起來就可以,這樣一來計時統計就已經失效了。如果涉及的是完全可控制的代碼層面的時候,簡單的解決方法可以是比對後不立即傳回,而是每次堅持把所有的字元都比對完之後再傳回。本着科學的精神,譯者接下來要回歸原文的直譯了。

前攝性安全的持久使用者認證

接下來要介紹的是我們在一個Web應用裡為“記住我”特性采用的一個政策,這個政策不會洩露任何資訊包括計時資訊,也就是每次查詢時長都是一緻的,而且查詢仍然是高效的(避免拒絕服務攻擊,俗稱DOS攻擊)。

我們建議的政策脫離了上面簡單的token登陸政策,重要的一步在于:我們沒有在cookie裡儲存token,而是儲存了selector:validator。

selector是一個用于查詢資料庫的唯一ID,可以預防計時攻擊,因為查詢時間是一緻的,這比使用id字段要好,因為id字段會洩露線上使用者人數。

CREATE TABLE `auth_tokens` (
    `id` integer(11) not null UNSIGNED AUTO_INCREMENT,    
    `selector` char(12),    
    `token` char(64),    
    `userid` integer(11) not null UNSIGNED,    
    `expires` datetime,    
    PRIMARY KEY (`id`)
);      

在資料庫端,validator并沒有儲存進去,儲存的是它的sha-256哈希值,在cookie裡面selector和validator儲存的都是明文,用這種方式,如果auth_tokens資料被洩露了,即時大面積的冒充使用者就不會産生。

這個算法概括起來是:

1. 從cookie中分離selector和validator

2. 用selector去查詢auth_tokens

3. 用sha-256計算validator的哈希值

4. 用hash_equals()函數來比對資料庫裡的值和剛才計算的哈希值

5. 如果以上都成功了,就可以将目前的session指向記錄中的使用者了

在這個部落格發表以後,我們的政策在GateKeeper實作了,如果你需要一個即時的解決方案,可以看一下這個庫。

重要事項:如果使用者更改密碼,所有目前的持久認證token都應該讓其失效。

找回密碼

讓我們直說好了:重設密碼功能就是一個後門。對于很多的應用和服務來說,他們都不合适而且也不應該實作。

通常來說,找回密碼系統有兩種問題:

1. 他們問很糟糕的安全問題,問題的答案通常對于使用者來說不是私密的.

2. 他們依賴于很不可靠的第二個認證因素(例如給使用者的手機或者郵箱發送一個随機的token)

安全提問的問題很清楚,第二種方法需要通路使用者的手機或者郵箱,那麼這就給攻擊者攻擊其他應用或者服務商這些相關的賬戶的機會。這很糟糕。

我們推薦這樣做:

1. 如果你能提供幫助的話就不要提供後門。

2. 不要使用安全提問如果使用者可能将答案公布在網絡上

3. (可選)允許使用者關聯一個GnuPG公鑰到他們的賬戶去。當接到一個賬戶恢複請求的時候,就用這個公鑰将token加密發送給使用者,那麼隻有擁有使用者私鑰的人才能夠解密得到這個token。我們在自己的項目中就使用了這種方法。

如果你的确需要實作一個找回密碼的後門(很多應用不管需不需要都提供),而且你的使用者并沒有那麼專業,不會使用GnuPB,最好的方法就是生成一個随機token(像上面提到的那樣使用加密安全随機函數),然後發到使用者的郵箱去。當他們完成這一步,準許他們重設密碼,永遠别發他們的舊密碼回去(也隻有你不hash的時候才有可能)。如果你儲存有他們的舊密碼的話,實在稱不上一個負責人的Web開發人員。

注意到發送敏感資訊到郵箱意味着你必須相信STARTTLS協定,一個機會型加密,對于一般觀察者來說沒問題,但是對于專業攻擊者來說就沒什麼用了。對此現在并沒有标準的廣泛使用的可靠的方案。