The HTB x Uni CTF 2020 - Qualifiers have just finished and I wanted to write-up some of the more interesting challenges that we completed.
As with many of the challenges the full source code was available including the files necessary to build and run a local docker instance of the service.
The application is a simple flask web app that takes screenshots of websites and returns the cached image to the user. It has protections to prevent requests of resources from localhost that can be circumvented using a DNS rebinding attack.
The first endpoint is at /cache and accepts a url (JSON over POST) and will take a screenshot of the url provided using a headless web browser and return the image.
@api.route('/cache', methods=['POST']) def cache(): print("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')
The second is at /flag and simply returns the flag.png image, however there is a check that refuses to server this unless the request is from 127.0.0.1. The challenge seems simple enough but requesting http://127.0.0.1/flag from the cache reveals that the cache blocks all IPs that equate to localhost.
def cache_web(url): scheme = urlparse(url).scheme domain = urlparse(url).hostname if scheme not in ['http', 'https']: return flash('Invalid scheme', 'danger') def ip2long(ip_addr): return unpack("!L", socket.inet_aton(ip_addr)) def is_inner_ipaddress(ip): print("IP (1): %s"%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 try: if is_inner_ipaddress(socket.gethostbyname(domain)): return flash('IP not allowed', 'danger') return serve_screenshot_from(url, domain) except Exception as e: print(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
There are a couple hints inside this challenge, one is in the web page's title 'Rebind me' and another inside an image in the source:
TOCTOU stands for Time of Check Time of Use, and along with the DNS rebinding hints it's apparent that we need to rapidly modify the DNS binding for a domain. For the first check to pass, the resolved IP address must not be local, however the second time the IP is resolved it needs to be local so that the headless browser will give a screenshot of the flag.
We can provide the following URL to the web applications caching service in order to complete the challenge, remembering to add
1337 as this is the port the web app is listening on inside the container:
This will now result in 3 different outcomes:
- FAIL The first check resolves to localhost which fails as it's not allowed in the cache_web function.
- FAIL - Both checks resolve to a remote address and the screenshot fails as the docker container does not allow internet traffic.
- WIN - The first check resolves to a remote address and the second one to localhost and we will get the flag!
This gives us the flag for this challenge, which we have to type out manually...