I made a service for people to cache their favourite websites, come and check it out! But don’t try anything funny, after a recent incident we implemented military grade IP based restrictions to keep the hackers at bay…

Category: web

Solver: davex, lmarschk

Writeup

The first look at the challenge already gave an intuition how the solution looks like. The title of the web page was Rebind Me. This hints that the solution might be a DNS Rebind attack.

The challenge had a pretty site where you can enter a url and received a screenshot of the given url

google-cache

Then we looked into the routes of the web application

@api.route('/cache', methods=['POST'])
def cache():
    if not request.is_json or 'url' not in request.json:
        return abort(400)
    
    return cache_web(request.json['url'])

@web.route('/flag')
@is_from_localhost
def flag():
    return send_file('flag.png')

There is a route called flag which is only reachable from the localhost. We checked the implementation of is_from_localhost but it seemed not vulnerable. After that, we looked up the source of the cache_web function and this is the function where the actual vulnerability is. We shorten the functions below to the important parts.

def serve_screenshot_from(url, domain, width=1000, min_height=400, wait_time=10):
    ...
    # Setup of the driver (headless chromium browser)
    driver.get(url)

    ...
    # Save the screenshow of the page
    filename = f'{generate(14)}.png'
    ...

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

    ...
    
    def is_inner_ipaddress(ip):
        ip = ip2long(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

    ...
    if is_inner_ipaddress(socket.gethostbyname(domain)):
        return flash('IP not allowed', 'danger')
    return serve_screenshot_from(url, domain)
 

As you can see the url given by the user input is resolved to an IP address and checked if it’s a localhost ip before it’s given to the screenshot function. This is the exact part that is vulnerable because of the serve_screenshot_from function only the url and not the resolved ip. This means that the chrome needs to resolve the url by itself again and cannot check if the resolved ip is the same. This is where a DNS rebind attack takes part. If we can achieve that the socket.gethostbyname(domain) function receives a valid IP (not a local ip) then there is a chance that the chrome will resolve it to a different ip.

This behavior only can occur if we can control a domain and the time to life (TTL) of this specific domain. Luckily we had both; a domain and a DNS server which we can fully control.

We set up the domain settings with a TTL of 5 seconds which means that the cached DNS entry at the challenge server is only for 5 seconds valid and needs to be refreshed after that.

Our DNS server settings looked like

domain.com  A   5   127.0.0.1
domain.com  A   5   a valid ip

This means if a service requests our domain it will either get the localhost ip or the valid ip. This means we hoped that the resolve function of the check will receive the valid ip and the chrome browser will resolve the same domain to a localhost ip.

One important part of the actual request is that the webserver is running on port 1337 so we need to request this port to and we also want to see the /flag path.

We send our domain with the port 1337 and the path /flag to the server and after a few tries we had luck and received a screenshot request

We looked up the received image and got the flag

flag

HTB{pwn1ng_y0ur_DNS_r3s0lv3r_0n3_qu3ry_4t_4_t1m3}