一個有趣的請求已開通針對PHP來作出bin2hex()一定的時間。這導致了一些關於郵件清單的有趣討論(甚至讓我回複:-X)。PHP在遠程計時攻擊方面的報道非常好,但他們談論了字串比較。我想談談其他類型的定時攻擊。
什麼是遠程定時攻擊?
好的,讓我們假設你有以下代碼:
function containsTheLetterC($string) { for ($i = 0; $i < strlen($string); $i++) { if ($string[$i] == "c") { return true; } sleep(1); } return false;}var_dump(containsTheLetterC($_GET['query']));
說出它的作用應該很容易。它接受query來自URL 的參數,然後逐字逐句通過它,檢查它是否是小寫字母c。如果是,它會返回。如果不是,它睡一秒鐘。
所以讓我們想象我們通過了字串?query=abcdef。我們預計檢查將花費2秒鐘。
現在,讓我們想象一下,我們不知道要尋找哪封信。讓我們想象一下,這"c"是一個我們不知道的不同價值。你能想出如何弄清楚那封信是什麼嗎?
這很簡單。我們構造一個字串"abcdefghijklmnopqrstuvwxyzABCDEFGHIJ....",並將其傳入。然後我們可以計算返回需要多長時間。然後我們知道哪個角色不同!
這是定時攻擊的基礎。
但是我們沒有在現實世界中看起來像那樣的代碼。我們來看一個真實的例子:
$secret = "thisismykey";if ($_GET['secret'] !== $secret) { die("Not Allowed!");}
為了理解發生了什麼,我們需要is_identical_function從PHP的原始碼中看到。如果你看看這個函數,你會發現結果是由以下情況定義的:
case IS_STRING: if (Z_STR_P(op1) == Z_STR_P(op2)) { ZVAL_BOOL(result, 1); } else { ZVAL_BOOL(result, (Z_STRLEN_P(op1) == Z_STRLEN_P(op2)) && (!memcmp(Z_STRVAL_P(op1), Z_STRVAL_P(op2), Z_STRLEN_P(op1)))); } break;
該if如果兩個變數都相同的變數(如主要是要求$secret === $secret)。在我們的情況下,這是不可能的,所以我們只需要看else塊。
Z_STRLEN_P(op1) == Z_STRLEN_P(op2)
所以如果字串長度不匹配,我們立即返回。
這意味著如果字串的長度相同,就可以完成更多的工作!
如果我們花費多長時間來執行不同的長度,我們會看到類似這樣的內容:
| 長度 |
時間運行1 |
時間跑2 |
時間運行3 |
平均時間 |
| 7 |
0.01241 |
0.01152 |
0.01191 |
0.01194 |
| 8 |
0.01151 |
0.01212 |
0.01189 |
0.01184 |
| 9 |
0.01114 |
0.01251 |
0.01175 |
0.01180 |
| 10 |
0.01212 |
0.01171 |
0.01120 |
0.01197 |
| 11 |
0.01210 |
0.01231 |
0.01216 |
0.01219 |
| 12 |
0.01121 |
0.01211 |
0.01194 |
0.01175 |
| 13 |
0.01142 |
0.01174 |
0.01251 |
0.01189 |
| 14 |
0.01251 |
0.01121 |
0.01141 |
0.01171 |
如果您忽略平均列,您會注意到似乎沒有多少模式。這些數字都在彼此的原因之內。
但是,如果您平均進行多次跑步,您就會注意到一種模式。你會注意到長度11需要更長的時間(略),然後是其他長度。
這個例子非常誇張。但它說明了這一點。它已經顯示了可以使用約49000(所以49000次嘗試,而不是在上述實施例3)的樣品大小遠程檢測的差異在時間縮短到約15納秒。
但是,我們發現了這個長度。那不會給我們太多的收入......但第二部分呢?那怎麼樣memcmp(...)?
如果我們看的執行memcmp()::
int memcmp(const void *s1, const void *s2, size_t n){ unsigned char u1, u2; for ( ; n-- ; s1++, s2++) { u1 = * (unsigned char *) s1; u2 = * (unsigned char *) s2; if ( u1 != u2) { return (u1-u2); } } return 0;}
等一下!這返回兩個字串之間的第一個區別!
所以一旦我們確定了字串的長度,我們可以嘗試不同的字串開始檢測差異:
axxxxxxxxxxbxxxxxxxxxxcxxxxxxxxxxdxxxxxxxxxx...yxxxxxxxxxxzxxxxxxxxxx
並通過相同的技術,發現與“txxxxxxxxxx”的差異比其他時間略長。
為什嗎?
讓我們看看在memcmp中一步一步發生的事情。
首先,它查看每個字串的第一個字元。
如果第一個字元不同,請立即返回。
接下來,看看每個字串的第二個字元。
如果它們不同,立即返回。
等等。
因此"axxxxxxxxxx",它只執行第一步(因為我們正在比較的字串"thisismykey")。但是"txxxxxxxxxx",第一步和第二步相匹配。所以它做更多的工作,因此需要更長的時間。
所以一旦你看到了,你知道t是第一個字元。
那麼這隻是一個重複這個過程的問題:
taxxxxxxxxxtbxxxxxxxxxtcxxxxxxxxxtdxxxxxxxxx...tyxxxxxxxxxtzxxxxxxxxx
為每個角色做到這一點,你就完成了。你已經成功推斷出一個秘密!
防止比較攻擊
所以這是一個基本的比較攻擊。==並且===在PHP中都容易受到攻擊。
有兩種基本的防禦方法。
首先是手動比較兩個字串,並且總是比較每個字元(這是我以前的部落格文章中的函數:
/** * A timing safe equals comparison * * @param string $safe The internal (safe) value to be checked * @param string $user The user submitted (unsafe) value * * @return boolean True if the two strings are identical. */function timingSafeEquals($safe, $user) { $safeLen = strlen($safe); $userLen = strlen($user); if ($userLen != $safeLen) { return false; } $result = 0; for ($i = 0; $i < $userLen; $i++) { $result |= (ord($safe[$i]) ^ ord($user[$i])); } // They are only identical strings if $result is exactly 0... return $result === 0;}
第二個是使用內建的PHP hash_equals() function。這是在5.6中添加的,與上面的代碼做同樣的事情。
註:一般情況下,它是不是能夠防止長度泄漏。所以可以泄漏這個長度。重要的部分是它不會泄漏關於兩個字串的差異的資訊。
其他類型的計時攻擊 - 索引尋找
那就是比較。這是相當好的覆蓋。但是讓我們來談談索引尋找:
如果您有一個數組(或字串),並且使用秘密資訊作為索引(鍵),則可能會泄漏有關該鍵的資訊。
為了理解為什麼,我們需要瞭解一下CPU如何處理記憶體。
通常,CPU具有固定寬度的寄存器。把這些想象成小變數。在現代處理器上,這些寄存器可能是64位(8位元組)寬。這意味著CPU可以在一次處理的最大變數是8個位元組。(注意:這是不正確的,因為大多數處理器都有基於向量的操作,例如SIMD,它允許它與更多的資料互動。對於這個討論來說,儘管這並不重要)。
那麼當你想讀一個長度為16位元組的字串時會發生什麼呢?
那麼,CPU需要載入它塊。根據操作的不同,它可能一次載入8個位元組的字串,並且一次對它操作8個位元組。或者更常見的是,它一次處理一個位元組。
所以這意味著它需要從某處擷取字串的其餘部分。這個“某處”是主存(RAM)。但記憶非常緩慢。像真的很慢。大約100ns。這是我們的15納秒閾值。
而且由於主記憶體非常慢,所以CPU在CPU本身上只有很少的記憶體空間來充當緩衝。實際上,它們通常有兩種類型的緩衝。它們具有特定於每個核心(每個核心都有自己的L1快取)的L1快取,也是特定於核心的L2快取,以及經常在單個晶片上的所有核心之間共用的L3快取。為什麼3層?由於速度:
| 記憶體類型 |
尺寸 |
潛伏 |
| L1緩衝 |
32KB |
0.5納秒 |
| L2快取 |
256KB |
2.5 ns |
| L3緩衝 |
4-16MB |
10-20納秒 |
| 記憶體 |
地段 |
60 - 100納秒 |
所以我們來看看在string[index]C字串(char \*字元數組)上做了什麼。想象一下你有這樣的代碼:
char character_at_offset(const char *string, size_t offset) { return string[offset]}
編譯器會將其編譯為:
character_at_offset: pushq %rbp .cfi_def_cfa_offset 16 .cfi_offset 6, -16 movq %rsp, %rbp .cfi_def_cfa_register 6 movq %rdi, -8(%rbp) movq %rsi, -16(%rbp) movq -16(%rbp), %rax movq -8(%rbp), %rdx addq %rdx, %rax movzbl (%rax), %eax popq %rbp .cfi_def_cfa 7, 8 ret .cfi_endproc
雖然有很多噪音。讓我們把它縮小到一個非功能性但更合適的尺寸:
character_at_offset: addq %rdx, %rax movzbl (%rax), %eax popq %rbp ret
該函數有兩個參數,其中一個是指標(字串的第一個元素),第二個是整數位移量。它將兩者相加以獲得我們想要的字元的記憶體位址。然後,movzbl從該地址移動一個位元組並將其儲存%eax(其餘零也為零)。
那麼,CPU如何知道在哪裡可以找到那個記憶體位址呢?
那麼,它會遍曆快取鏈,直到找到它。
因此,如果它在L1緩衝中,整體操作大約需要0.5 ns。如果它在L2,2.5ns。等等。因此,通過仔細計算資訊的時間,我們可以推斷出該項目被緩衝的位置(或者它是否被緩衝)。
值得注意的是,CPU不會緩衝單個位元組。他們緩衝稱為線的記憶體塊。現代處理器通常具有64位元組寬的快取行。這意味著緩衝中的每個條目都是連續的64位元組的記憶體塊。
所以,當你進行記憶體提取時,CPU會將一個64位元組的記憶體塊寫入緩衝行。因此,如果您的movzbl調用需要打到主記憶體,整個塊將被複製到較低的緩衝行中。(請注意,這是一個非常簡單的事情,但它是為了示範下一步會發生什麼)。
現在,這裡是真正有趣的地方。
假設我們正在處理一個大字串。一個不適合二級緩衝。所以1MB。
現在,讓我們設想一下,我們從基於數位秘密序列的字串中提取位元組。
通過觀察擷取位元組需要多長時間,我們實際上可以確定關於秘密的資訊!
讓我們想象我們擷取以下位移量:
第一次提取會導致緩衝未命中,將從主記憶體載入到緩衝中。
但是第二個fetch(offset 1)將從L1緩衝中擷取,因為它可能與原來的緩衝行(記憶體塊)相同offset 10。所以它很可能是快取命中。
如果我們然後提取offset 2048,它很可能不在緩衝中。
因此,通過仔細觀察延遲模式,您可以確定有關位移序列關係的一些資訊。通過多次使用正確的資訊來做到這一點,你可以推斷出這個秘密。
這被稱為緩衝時間攻擊。
現在看起來真的很牽強,對吧?我的意思是,你有多頻繁地擷取完美的資訊?這怎麼可能是實際的。那麼,這是100%的實際,並發生在現實世界中。
針對緩衝時間攻擊的防禦:
只有一種防禦這種風格攻擊的實用方法:
不要通過秘密索引數組(或字串)。
這真的很簡單。
基於分支的計時攻擊
你看過幾次類似下面的代碼?
$query = "SELECT * FROM users WHERE id = ?";$stmt = $pdo->prepare($query);$stmt->execute([$_POST['id']]);$user = $stmt->fetchObject();if ($user && password_verify($_POST['password'], $user->password)) { return true;}return false;
當然,這是安全的?
那裡有資訊洩漏。
如果您嘗試使用不同的使用者名稱,則根據使用者名稱是否存在需要不同的時間。如果password_verify需要0.1秒,您可以簡單地測量該差異來確定使用者名稱是否有效。平均而言,對使用使用者名稱的請求將花費比可用使用者名稱更長的時間。
現在,這是一個問題嗎?我不知道,這取決於你的要求。許多網站希望保留使用者名的秘密,並盡量不公開他們的資訊(例如:不說使用者名稱或密碼在登入表單中是否無效)。
如果你想保持使用者名稱的秘密,你就不會。
防禦基於分支的計時攻擊
做到這一點的唯一方法是不分支。但那裡有問題。如果你不分支,你如何獲得像上面那樣的功能?
那麼,一個想法是執行以下操作:
$query = "SELECT * FROM users WHERE id = ?";$stmt = $pdo->prepare($query);$stmt->execute([$_POST['id']]);$user = $stmt->fetchObject();if ($user) { return password_verify($_POST['password'], $user->password);} else { password_verify("", DUMMY_HASH);}return false;
這意味著你password_verify在兩種情況下運行。這削減了0.1第二個區別。
但核心計時攻擊依然存在。原因在於資料庫將返回查詢的時間稍微有點不同,以尋找找到該使用者的查詢,以及尋找不到的查詢。這是因為它在內部執行大量分支和條件邏輯,最終需要通過線路將資料轉送回程式。
所以防禦這種風格攻擊的唯一方法就是不要將您的使用者名稱視為秘密!
關於“隨機延遲”的一個註記
許多人在聽到定時攻擊時,都會想:“呃,我只是隨意添加一個延遲!這將工作!“。而事實並非如此。
要理解為什麼,讓我們來談談添加隨機延遲時實際發生的情況:
整體執行時間是work + sleep(rand(1, 10))。如果蘭德行為良好(這是隨機的),那麼隨著時間的推移,我們可以將其平均。
讓我們說這是rand(1, 10)。那麼,這意味著當我們平均運行時,平均延遲約為5.相同的平均值加到所有情況下。所以我們需要做的就是每運行一次運行一次以平均噪音。我們啟動並執行次數越多,隨機值越傾向於平均。所以我們的訊號仍然存在,它只需要稍微更多的資料來對抗雜訊。
因此,如果我們需要運行49,000次測試以獲得15ns的準確度,那麼我們需要大概100,000或1,000,000次測試來獲得相同的準確度和隨機延遲。或者可能達到100,000,000。但資料仍然存在。
修複漏洞,不要僅僅在它周圍增加噪音。
有效實際延遲
隨機延遲不起作用。但我們可以通過兩種方式有效地使用延遲。第一個是更有效,也是我唯一“依靠”的一個。
延遲取決於使用者輸入。
因此,在這種情況下,您可以使用本地金鑰組使用者輸入進行雜湊處理,以確定要使用的延遲:
function delay($input, $secret_key) { $hash = crc32(serialize($secret_key . $input . $secret_key)); // make it take a maximum of 0.1 milliseconds time_nanosleep(0, abs($hash % 100000));}然後只需將使用者輸入用於延遲功能。這樣,隨著使用者改變他們的輸入,延遲也會改變。但它會以同樣的方式改變,使他們無法用統計技術來平均它。
請注意,我使用過crc32()。這不需要是加密散列函數。由於我們只是派生一個整數,所以我們不需要擔心碰撞。如果您希望更安全,您可以用SHA-2功能替換它,但我不確定這是否值得速度損失。
使操作花費最少時間(夾緊)
因此,許多人浮現的想法是將操作“夾”到特定的運行時(或者更準確地說,使其至少需要一定的已耗用時間)。
function clamp(callable $op, array $args, $time = 100) { $start = microtime(true); $return = call_user_func_array($op, $args); $end = microtime(true); // convert float seconds to integer nanoseconds $diff = floor((($end - $start) * 1000000000) % 1000000000); $sleep = $diff - $time; if ($sleep > 0) { time_nanosleep(0, $sleep); } return $return;}所以你可以說比較必須花費最少的時間。因此,不要試圖比較一直持續的時間,你只需要花時間。
所以,你可以鉗住等於100納秒(clamp("strcmp", [$secret, $user], 100))。
這樣做,你保護了字串的第一部分。如果前20個字元花費了100納秒,那麼通過鉗位到100納秒,可以防止那些泄漏的差異。
但是有一些問題:
它非常脆弱。如果你時間太短,你會失去所有的保護。如果時間過長,可能會在應用程式中增加不必要的延遲(如果不小心,可能會暴露DOS風險)。
它實際上並沒有保護任何東西。它只是掩蓋了這個問題。我認為這是一種通過默默無聞的安全形式。這並不意味著它沒有用或無效。這隻是意味著風險。很難知道它是否確實有效地讓你更安全,或者讓你在夜晚更好地睡覺。當在圖層中使用時,它可能是好的。
它不能防止本地攻擊者。如果攻擊者可以在伺服器上獲得代碼(甚至是未經授權的,在不同的使用者帳戶上,如共用伺服器上),則他們可以查看CPU使用方式,從而可以看到過去的睡眠狀況。這是一個延伸,在這種情況下可能會有更有效攻擊,但至少值得注意。
防禦DOS攻擊
所有這些技術都需要很多請求。它們基於依靠大量資料有效“平均”雜訊的統計技術。
這意味著要獲得足夠的資料來實際執行攻擊,攻擊者可能需要製造數千,數十萬甚至數百萬的請求。
如果你正在練習好的DOS保護技術(基於IP的速率限制等),那麼你將能夠繞過很多這些風格的攻擊。
但是DDOS保護難以防範。通過分配流量,防範難度更大。但對攻擊者來說也更難,因為他們有更多的噪音需要處理(而不僅僅是本地網段)。所以這並不太實際。
但是就像安全的任何事情一樣,縱深防禦。即使我們認為這次攻擊是不可能的,但如果我們原來的保護失敗,仍然值得保護它。深度使用防禦,我們可以讓自己在各種規模的攻擊中更具彈性。
回到點
目前有關PHP內部的一個關於是否使某些核心功能的時序安全與否的線索。正在討論的具體功能是:
bin2hex
hex2bin
base64_encode
base64_decode
mcrypt_encrypt
mcrypt_decrypt
現在,為什麼這些功能?井,bin2hex和base64_encode編碼輸出到瀏覽器(編碼會話參數例如)當經常使用。然而,更重要的是hex2bin和base64_decode,因為它們可以用於解密秘密資訊(就像在將密鑰用於加密之前的密鑰)。
到目前為止,大多數受訪者的共識是,為了獲得更多的安全,不值得讓它們變得更慢。我同意這一點。
但是,我不同意的是,它會讓它們“變慢”。更改比較(從)==到hash_equals較慢是因為它將函數的複雜性(最佳,平均,最差)從O(1, n/2, n)更改為O(n, n, n)。這意味著它將對平均情況下的效能產生重大影響。
但改變編碼功能不會影響複雜性。他們將繼續O(n)。所以問題是,速度差是多少?那麼,我用PHP演算法和一個時間安全的標準對bin2hex和hex2bin進行了基準測試,差異不是太顯著。編碼(bin2hex)大致相同(誤差範圍),並且解碼的差異(hex2bin)大約為0.5μs。對於大約40個字元的字串,這是5e-10秒多。
對我而言,這足夠小,根本不用擔心。平均應用程式調用其中一個受影響的函數多少次?也許每一次執行可能?但有什麼潛在的好處?這可能是一個漏洞被阻止?
也許吧。我認為沒有充足的理由去做這件事,一般而言,這些漏洞在用PHP編寫的應用程式類型中將非常困難。但有了這個說法,如果實施過程足夠快速(對我來說,0.5μs足夠快),那麼我認為沒有一個重要的理由不去做這個改變。即使它有助於防止所有數百萬PHP使用者的單一攻擊,這是否值得?是。它會阻止單一攻擊嗎?我不知道(可能不)。
但是,我認為有幾項功能必須不斷進行時間安全審計:
mcrypt_\*
hash_\*
password_\*
openssl_\*
md5()
sha1()
strlen()
substr()
基本上,我們所知道的任何東西都會與敏感資訊一起使用,或者將在敏感操作中用作原語。
至於字串函數的其餘部分,或者沒有必要讓它們的時間安全(像lcfirst或strpos),或者它不可能(像trim)或已經完成(像strlen),或者它沒有任何業務在PHP(如hebrev)...
跟進
因此,HackerNews和Reddit發布了這篇文章。評論有幾個共同的主題,所以我會在這裡跟進。我還編輯了文章內聯來解決這些問題。
不可能使代碼保持恒定時間
那麼,我應該澄清“不變”的含義。在絕對意義上,我並不是指不變的。我的意思是不變的相對於秘密。這意味著時間不會依賴於我們試圖保護的資料而改變。因此總體而言,絕對時間可能由於許多原因而波動。但我們不希望我們試圖保護的價值影響它。
這是區別:
for ($i = 0; $i < strlen($_GET['input']); $i++) { $input .= $_GET['input'][$i];}
這是可變的時間,但泄漏什麼是秘密,
和
$time = 0;for ($i = 0; $i < strlen($_GET['input']); $i++) { $time += abs(ord($_GET['input'][$i]) - ord($secret[$i]));}sleep($time);
現在,這是一個荒謬的例子。但它表明,兩者都會根據投入改變時間,但也會因我們試圖保護的秘密而有所不同。這就是我們說“恒定時間”時的意思,而不是基於秘密的價值而變化。
怎樣鉗制一個特定的已耗用時間?
我已經在文章的主體中解決了上述問題。
不保護DOS的工作?
是。我已經將其添加到防禦列表中。但考慮到它對DDOS不起作用(雖然時間差異很難識別),但我不會因為這個原因而忽略它。
這不實用
那麼,事實並非如此。有視頻和檔案和工具以及更多工具和更多論文以及更多視頻。
所以如果攻擊者一直在談論這件事情,那肯定是有好處的。
但是,成功利用計時攻擊需要很多工作。因此,攻擊者通常會尋找更容易和更常見的攻擊,例如SQLi,XSS,遠程代碼執行等,但這實際上取決於更多因素。如果您正在保護部落格網站的工作階段識別項,那麼您可能不必擔心它。但是,如果您保護用於加密信用卡號碼的加密金鑰......
從實際的角度來看,我不會擔心定時攻擊,除非我確信其他潛在的媒介是安全的。就這樣說,我認為這很有趣,值得瞭解。但是像安全和編程中的其他一切一樣,這都是關於權衡的。