Привет! В прошлый раз я рассказывал про содержательную форензику на НТО по информационной безопасности. В этот раз хотел бы поговорить о новом и еще более интересном формате, встретившемся на этом соревновании – поиске и закрытии уязвимостей! И, хоть два этих этапа не связаны друг с другом, я советую вам сперва прочесть предыдущую статью – вся полезная информация о соревновании, кстати, тоже там.
Устраивайтесь поудобнее и готовьтесь к путешествию…

Легенда

И начнем мы, по старой доброй традиции, с легенды, которую приготовили авторы соревнования (отныне гордо именующие себя DERED). Не забывайте, что начало истории находится в предыдущей статье по ссылке выше! :)

К делу подключился папа Валеры. Совместными усилиями они выяснили, что главная цель хакера, к которой он успешно получил доступ – веб-приложение, управляющее главной распределительной электростанцией города. Но подоспели они как раз вовремя – злоумышленник лишь пытается вторгнуться в систему. От отца толку мало – он ушел за хлебом обустраивать файрволл, так что мы вновь предоставлены сами себе.

В действительности, мы играем против беспощадного (или не такого уж?) чекера авторов, и, по классике, объясняем жюри, что вообще происходит у нас на машине и почему это работает. Сервис написан на Python’e – с использованием фреймворка Flask – и уязвим до неприличия… Залатыванием дыр мы и займемся!

Но прежде минутка полезных ссылок: все исходники соревнования (в том числе и этого этапа) можно найти здесь, а версию со всеми фиксами, над которой работал я, тут. Рекомендую открыть какой-нибудь из проектов и следить за происходящим – для большего погружения.

Подготовка

Нам дан Docker-контейнер уязвимого сервиса и SSH-доступ к удаленной машине, на которой он развернут – для внесения изменений.

Для начала заглянем в docker-compose.yml:

Видим, что из сервисов у нас есть MongoDB, самописный emulator (который нас пока мало интересует), control и nginx, ответственный за представление веб-сервиса на порту 8080.
Также наблюдаем сеть xss, в которую подключены все сервисы.

Разбираемся по порядку.

Вторгаемся в XSS

Поднимем локально Докер (docker-compose up). Попробуем подключиться к localhost:8080, однако упремся в необходимость авторизации:

Заглянем в файлик htpasswd (прописанный в auth_basic_user_file), в котором обнаружится хеш единственного юзера: xss:$apr1$U5jaLGDS$wNkE7MlW5UaHzcUsFzaCb.

Брут хеша успехом не увенчался, поэтому было принято решение добавить (с помощью утилиты htpasswd) отдельного юзера с известным паролем. Позднее выяснилось, что так и нужно было сделать, потому что учетка xss имеет пароль n5fptvc7CWwjlgJkU8F2 и предназначена исключительно для работы чекера.
Но на туре это было совершенно неочевидно, и некоторые из команд не справились даже с этим этапом. Заодно и реалистичность атаки падает практически до нуля, потому что пароль аутентификации на ресурс имеет энтропию в 120 бит и нигде ранее не упоминался – злоумышленник никак не мог его получить.

Добро пожаловать, или Посторонним вход воспрещён

Успешно проходим базовую аутентификацию и переходим к самому сервису.
Встречает нас следующая неприметная формочка авторизации:

Методом научного тыка выясняем, что для анализа данной формы нам понадобятся файлы control.py и access.py из папки control/src.
Исходный код функции, ответственный за обработку параметров логина:

@app.route('/login', methods=['GET', 'POST'])
def login():
    if request.method == 'GET':
        return render_template("login.html")
    username = request.json.get('username')
    password = request.json.get('password')
    try:
        user = User(connector)
        user.import_from_db(username=username, password=password)
        session["user"] = username
        return make_response({"status": "ok", "user_id": user.id}, 200)
    except UserDoesntExist:
        return make_response({"error": "User not found"}, 404)
    except WrongPassword:
        return make_response({"error": "Wrong password"}, 403)

Видим конструкцию try/except, в которой уже заключена уязвимость – несмотря на то, что при некорректной попытке авторизации пользователю выводится alert с шаблонной фразой “Invalid username or password”, по коду ответа можно установить, что в действительности не так – юзернейм не существует или пароль не валиден. Это может стать проблемой, так как поможет эффективно перебрать логины большинства действительно существующих на ресурсе пользователей и в дальнейшем использовать это для атаки.

Предлагается следующий фикс:

@app.route('/login', methods=['GET', 'POST'])
def login():
    if request.method == 'GET':
        return render_template("login.html")
    username = request.json.get('username')
    password = request.json.get('password')
    try:
        user = User(connector)
        user.import_from_db(username=username, password=password)
        session["user"] = username
        return make_response({"status": "ok", "user_id": user.id}, 200)
    except (UserDoesntExist, WrongPassword):
        return make_response({"error": "Invalid username or password"}, 403)

Также нетрудно заметить, что единственное место в блоке try, где действительно может быть брошено исключение – это вызов функции import_from_db из модуля access. Перемещаемся к ее исходному коду:

def import_from_db(self, username='', password='', user_id=None):
    if user_id != None:
        user_id = int(user_id)
        user = self._connector.db.get_user_by_uid(user_id)
    else:
        user = self._connector.db.get_user(username)
    if user == False:
        raise UserDoesntExist
    self.email = user['email']
    self.username = user['username']
    self._hashed = user['password']
    self.password = ''
    self.admin = user['admin']
    self.id = user['uid']
    if password:
        password = self._connector.db.hash_password(password)
        if password != self._hashed:
            raise WrongPassword
    permissions = self._connector.db.get_permissions(self.username)
    if permissions:
        self.permissions = self.__import_permissions(permissions)

Видим, что функция предназначена для использования в разных контекстах.
Например, в таком (без указания пароля):

user = User(connector)
user.import_from_db(session['user'])
if not user.check_permissions(element_path, method):
    return make_response({'error': 'Access denied'}, 403)

Или в таком (исключительно по ID пользователя):

user = User(connector)
user.import_from_db(user_id=user_id)
user.import_({"permissions": permissions})
user.save_to_db()

В этом и кроется косяк разработчика – чтобы разграничить использование связки username/password и получения объекта пользователя по его юзернейму, используется значение для пароля по умолчанию – пустая строка, что в проверке if password вернет False. Но никто не запрещал нам ввести пустой пароль в форму авторизации… обойдя тем самым процесс хеширования и получив вполне себе полноценный объект пользователя. Например, админа…

В качестве фикса можно предложить добавить в уже известную нам функцию login следующие строки:

if not password:
    return make_response({"error": "Empty password is not allowed"}, 403)

Следом за логином разберем и регистрацию:

@app.route('/register', methods=['GET', 'POST'])
def register():
    if request.method == 'GET':
        return render_template("register.html")
    email = request.json.get('email')
    username = request.json.get('username')
    password = request.json.get('password1')
    password_ = request.json.get('password2')
    if password != password_:
        return make_response({"error": "Passwords do not match"}, 400)
    user = User(connector, email=email, username=username, password=password)
    if user.save_to_db():
        return make_response({"status": "ok"}, 200)
    else:
        return make_response({"error": "User exists!"}, 403)

Видим, что вердикт зависит исключительно от результата возврата функции save_to_db – если она возвращает True, регистрация успешна, если же False – появляется ошибка о том, что пользователь уже существует. Переходим к ней:

def save_to_db(self):
    user = self._connector.db.get_user(self.username)
    permissions = self._connector.db.get_permissions(self.username)
    if not permissions:
        permissions = self.__export_permissions(self._connector.get_permissions_template())
    if user != False:
        if self.password == '' or self._hashed == '':
            self._hashed = self._connector.db.hash_password(self.password)
        data1 = ':'.join([self.email, self.username, self._hashed, json.dumps(self.__export_permissions(self.permissions))])
        data2 = ':'.join([user['email'], user['username'], user['password'], json.dumps(permissions)])
        hash1 = md5()
        hash1.update(data1.encode())
        h1 = hash1.hexdigest()
        hash2 = md5()
        hash2.update(data2.encode())
        h2 = hash2.hexdigest()
        if h1 == h2:
            return False
        self._connector.db.delete_user(self.username)
        self._connector.db.delete_permissions(self.username)
    id_ = self._connector.db.new_user(self.username, self.email, self.password, self.admin)
    id_ = self._connector.db.set_permissions(self.username, self.__export_permissions(self.permissions))
    return True

Заметим, что в переменных h1 и h2 хранятся MD5-хеши строк вида email:username:password:permissions для нового и уже существующего пользователей (в случае, если коллизия существует – иначе все просто).
Если они равны (то есть мы пытаемся записать в базу данных идентичного юзера), регистрация не пройдет – такой пользователь действительно существует. Зато что-то интересное происходит, когда хоть какие-то данные для регистрируемого и существующего пользователей отличаются… пользователь перезаписывается с новыми данными – то есть правами или паролем!

Совершенно очевидно, что здесь логика программы инвертирована – никакой проблемы, если пользователь зарегистрирует идентичного пользователя… зато вот если хоть какие-то данные (то есть их хеши) отличаются – регистрацию следует отклонить.
Поэтому смело заменяем “равно” на “не равно” и закрываем уязвимость:

if h1 != h2:
    return False

Так я думал на соревновании. Так я думал, когда садился писать этот райтап. Но что-то пошло не так… Ниже целый параграф, почему все не так просто, как казалось на первый взгляд!

Почему авторы облажались

Как я уже упоминал в начале, DERED выкатили сурцы чекера, которым я не преминул воспользоваться (чтобы убедиться, что то, о чем я вам тут пишу, правда вообще)… и заработал себе головную боль в виде такого вердикта health checker’a сервиса после пресловутого фикса:

"check": {
    "status": "CORRUPT",
    "trace": "Permissions were not set",
    "description": ""
  }

Заглянем в функцию чека, где вызывается эта ошибка:

set_permissions(session_admin, url, user_id, export_permissions(permissions))
permissions_ = get_permissions(session_admin, url, user_id).json()['permissions']
if import_permissions(permissions_) != permissions:
    return STATUS_CORRUPT, 'Permissions were not set', ''

Видим, что функция set_permissions из модуля control захворала. Чем это может быть вызвано? Навестим пациентку:

@app.route('/set_permissions', methods=['POST'])
def set_permissions():
    user_id = request.json['user_id']
    permissions = request.json['permissions']
    user = User(connector)
    user.import_from_db(user_id=user_id)
    user.import_({"permissions": permissions})
    user.save_to_db()
    return make_response({"status": "ok"}, 200)

Вспоминаем, что наш последний фикс как раз затрагивал функцию save_to_db. Выясняется, что функции set_permissions как раз-таки нужно перезаписывать параметры пользователя, а функции register это следует строго-настрого запретить.
Как это можно реализовать? А давайте обратимся к авторам – наверняка они придумали какой-то более элегантный фикс:

При регистрации создается объект класса user нового пользователя и сохраняется в БД с помощью save_to_db(). Если указать пустой пароль и username существующего пользователя, то сработает условие if self.password == '' or self._hashed == '', что перезапишется в коде существующего пользователя. Самый простой фикс - добавить проверку password != '' в /register

Как выясняется, авторы придумали (и пофиксили) лишь частный случай найденной нами уязвимости – когда поле пароля в “повторной регистрации” мы оставляем пустым.
В чем мы можем еще раз убедиться, взглянув на авторский эксплоит:

username = generate_str()
password = generate_extended_str()
email = generate_email()

register(self.session, self.url, username, email, password, password)
login(self.session, self.url, username, password)
register(self.session, self.url, username, email, '', '')
login(self.session, self.url, username, password)

Впрочем, я последовал их рекомендациям и написал проверку на пустой пароль. Чекер молчит… Зато пользователь с credentials meow:meow вполне спокойно переписывается на пользователя с кредами meow:purr. Забавно.

Получается, что фикс придумывать придется нам самим. Я на скорую руку соорудил параметр rewrite_access, который в одном контексте равен True (и позволяет перезаписывать данные существующих пользователей при определенных условиях), а в другом – False (таким образом, перерегистрация существующего пользователя в любом случае невозможна).
Если у вас есть идеи более элегантного решения – смело пишите их в комментарии!

Моё золото, моё!

Во время исследования той самой “проблемной” функции set_permissions обнаружилась еще одна уязвимость – у всех ее собратьев есть декоратор is_admin, разграничивающий доступ к админским функциям:

@app.route('/get_permissions', methods=['GET'])
@access.is_admin
def get_permissions(): 

А про нее, бедненькую, забыли:

@app.route('/set_permissions', methods=['POST'])
def set_permissions():

Добавляем декоратор и получаем критическую уязвимость в отчет (которая, кстати, может сыграть очень злую шутку вкупе с предыдущей уязвимости перезаписи учетки) – это, наверное, самый простой фикс за весь разбор. Продолжаем исследование!

Уилл Смит и ваши пароли

Как у любого уважающего себя сервиса, у нашего есть функция помощи несчастным, забывшим свой пароль… и в ней не обошлось без дыры:

@app.route('/change_password', methods=['POST', 'GET'])
def change_password():
    user = connector.db.get_user(session["user"])
    if int(user["uid"]) != int(request.form.get('user_id')) and not user["admin"]:
        return make_response({"error": "Access denied"}, 403)
    user_id = request.values.get('user_id')
    password = request.values.get('password')
    if connector.db.change_password(user_id, password):
        return make_response({"status": "ok"}, 200)
    else:
        return make_response({"error": "Same password"}, 400)

Сначала мы проверяем на валидность user_id из request.form (отвечающему за тело POST-запроса)… а потом на сцену выходит таинственная фигура request.values , хранящая в себе и GET-, и POST- параметры (именно в таком порядке!). Значит ли это, что мы можем передать разные параметры в GET- и POST- тело и тем самым переписать чей-нибудь пароль, обойдя проверку валидности? Именно!

Этим и займемся. Правда, нам потребуются два валидных айдишника юзеров (свой и жертвы), но это не такая уж и проблема – свой выяснить можно, например, при помощи куки (которая так и называется), а в качестве жертвы выступит уже хорошо известный нам админ с первым айди. Генерируем запрос (для этого я воспользуюсь программкой Postman, но вполне можно обойтись каким-нибудь питоном):

Успех! Теперь о том, как это закрывать – достаточно сосредоточиться на каком-то одном источнике информации, в частности, теле POST-запроса:

@app.route('/change_password', methods=['POST', 'GET'])
def change_password():
    user = connector.db.get_user(session["user"])
    if int(user["uid"]) != int(request.form.get('user_id')) and not user["admin"]:
        return make_response({"error": "Access denied"}, 403)
    user_id = request.form.get('user_id')
    password = request.form.get('password')
    if connector.db.change_password(user_id, password):
        return make_response({"status": "ok"}, 200)
    else:
        return make_response({"error": "Same password"}, 400)

Fear the Cookie Monster…

…вот вам смешно, а это дельная рекомендация авторам сервиса!

Ни вам флага Secure (отвечающего за передачу кук только по протоколу HTTPS – для защиты от MitM-атак), ни флага HttpOnly (для защиты кук от редактирования при помощи скрипта на странице, например, воспользовавшись XSS).

Приходим с такой жалобой к жюри и получаем немного, но честным трудом заработанных баллов!

Ну и куда без XSS в сервисе XSS?

И правда. eXtreme Security System же. Чтобы обнаружить XSS-ку, придется постараться… зато какую XSS-ку – stored, так еще и прямо на страничке админа (в файле control\src\templates\admin_user.html)!

const response = await fetch(`/get_user?user_id=${user_id}`);
const user = (await response.json()).user;
var html = `<div class="form-group">
		<label for="username-input">Username:</label>
		<input id="username-input" type="text" class="form-control" value="${user.username}" readonly>
    </div>
    <div class="form-group">
        <label for="email-input">Email:</label>
        <input id="email-input" type="email" class="form-control" value="${user.email}" readonly>
    </div>
    <div class="form-group">
        <label for="password-input">Password:</label>
        <input id="password-input" type="password" class="form-control" value="${user.password}" readonly>
    </div>`
var checked = ''
if (user.admin) {
    checked = checked;
}
html += `<div class="form-group">
		<label for="admin-checkbox">Admin:</label>
		<input id="admin-checkbox" type="checkbox" class="form-control" ${checked} disabled>
	</div>
    <div class="form-group">
        <label for="id-input">ID:</label>
        <input id="id-input" type="text" class="form-control" value="${user.uid}" readonly>
    </div>`
document.querySelector('#place').innerHTML = html;

Видим, что провернуть XSS-ку будет совсем несложно – разработчик использует innerHTML. Давайте сконструируем пэйлоад, который в дальнейшем и будем использовать! Я предлагаю остановиться на видоизменении поля email.

Важно помнить, что при встраивании кода в DOM подобным образом (с помощью innerHTML) простая вставка тега <script> не прокатит. Но есть другой, проверенный временем, способ – параметр onload у тега img! В качестве картинки под пэйлоад я выберу какое-нибудь милое фото котят, а в качестве самого вредоносного кода функцию alert – достаточно безобидная достаточно классика.

"><img onload="alert('xss in action')" src="..."><div

Наш пэйлоад готов – приступаем к его применению! Зарегистрируем нового пользователя…

…и зайдем на его страничку от лица админа:

Милых котят админу в ленту ;D

Но, разумеется, при желании картинку с пэйлоадом можно и скрыть – с помощью тега hidden.

Теперь же уязвимость следует закрыть. Но вместе с ней было бы неплохо исправить сразу несколько плохих практик автора:

  • Чекбокс admin-checkbox никогда не оказывается отмеченным из-за бездарно написанного обработчика:

    var checked = ''
    if (user.admin) {
        checked = checked;
    }
    
  • Хеш пароля на странице фактически присутствует в открытом виде – если к этой странице получит доступ злоумышленник (а, как мы выяснили раньше, способов для этого у него была масса), учеткам пользователей грозит опасность. Поэтому хорошей практикой будет не отображать пароли ни в каком виде.

Для этого предлагается перенести непосредственно в HTML-код весь DOM, остающийся неизменным, а получаемые от пользователя значения присвоить позднее, при помощи JavaScript’a:

const response = await fetch(`/get_user?user_id=${user_id}`);
const user = (await response.json()).user;
document.getElementById('username-input').value = user.username;
document.getElementById('email-input').value = user.email;
document.getElementById('admin-checkbox').checked = user.admin;
document.getElementById('id-input').value = user.uid;

Убеждаемся, что все работает:

Чекер подтверждает наш успех.

А слона-то мы и не приметили!

Самое время осведомиться, как там здоровье у базы данных – сердца системы и еще одного местечка, что может подвести в авторизации.

А там кошмар, антисанитария! Везде используется $where – а он, как известно, подвержен NoSQL-инъекциям, ведь в сути своей исполняет JavaScript-код:

def get_user(self, username: str):
    self.__check_connection()
    try:
        u = self.users.find({"$where": f"this.username == '{username}'"})[0]
        return u
    except IndexError:
        return False

Подобные уязвимости позволяют злоумышленникам легко обходить авторизацию пэйлоадами, внедряемыми в поле username и содержащими операцию логического ИЛИ.

Фикс заключается в использовании более современного синтаксиса, предоставляемого Mongo API:

def get_user(self, username: str):
    self.__check_connection()
    try:
        u = self.users.find({"username": username})[0]
        return u
    except IndexError:
        return False

Вот уже почти как три тысячи слов написано, целых 5 способов завладеть админскими правами разобрано, а мы еще не добрались до исследования самой сути сервиса… Этим и займемся!

Прав мало не бывает?

Первым делом хочется проверить функцию сверки прав доступа юзера и элемента:

def check_permissions(self, element_path, method):
    if self.admin:
        return True
    el = self.get_permission(element_path)
    element_path = element_path.split('.')
    item_permissions = self._connector.get_abi()[self._connector.get_abi_element_original_name(element_path[-1])][method]
    if type(el) == Permissions and el != Permissions.NONE and (el & item_permissions):
        return True
    return False

Заметим, что для прав элемента Permissions.READ | Permissions.WRITE (используется общеизвестная нотация enum.Flag) и прав пользователя Permissions.READ операция логического И вернет Permissions.READ, что преобразуется в булево значение True. Выходит, что проверка реализована некорректно и дает доступ к элементу практически во всех ситуациях.

Воспользуемся старым добрым трюком: заменим (el & item_permissions) на (el & item_permissions) == item_permissions. Так мы убеждаемся, что у пользователя есть хотя бы все необходимые для элемента права.

Второе дыхание – SSRF

И, наконец, последняя, самая сложная в исполнении и поиске атака – SSRF!

SSRF кроется в элементе ServerRoom, и здесь самое время вспомнить про неизведанный сервис emulator. Сурцы элемента находятся по адресу emulator/src/elements/serverroom.py:

def set_backup_url(self, url: str):
    self.backup_url = url

def get_backup_url(self):
    return self.backup_url

def backup(self):
    self.backup_date = str(datetime.now())

def update_data(self, data: str):
    self.data = json.loads(b64decode(data))

def send_backup(self):
    try:
        answer = self._session.post(self.backup_url, json=self.data)
        return answer.json()
    except Exception as e:
        print(e, flush=True)
        return False

Заметим, что backup_url никак не валидируется. А в другом файле того же сервиса (emulator.py) найдем следующий эндпоинт, откатывающий состояние сервиса до исходного:

@app.route('/reset_state', methods=['POST'])
def reset():
    global state
    try:
        state = State('/dump.json')
        return make_response({'status': 'ok'}, 200)
    except Exception as e:
        print(e, flush=True)
        return make_response({'error': True}, 500)

Сервис эмулятора, в свою очередь, развернут на 8888 порту внутренней сети xss, и получить к нему доступ извне невозможно. Все признаки SSRF налицо!

Итак, что нам нужно для эксплуатации уязвимости? Каким-либо образом получить права на чтение и запись в разделе ServerRoom (для этого мы с вами нашли целых три пути – получение админских привилегий, самостоятельная установка прав и некорректная сверка прав элементов), выставить backup_url на http://127.0.0.1:8888/reset_state, создать и отправить бэкап (фактически, запрос на указанный URL). Профит!

Фикс же этой проблемы очень прост – достаточно добавить валидацию локальных адресов в backup_url:

if '127.0.0.1' in self.backup_url or 'localhost' in self.backup_url:
    return False

Эпилог

Друзья, это было непростое приключение, но я надеюсь, что вы справились и вам было так же интересно, как и нам на олимпиаде ;D

В компании отца наши старания оценили по достоинству – оболтус Валера получил оффер на должность инфобезника. Но мы на него не в обиде – ведь тем, кто спас положение, он притащил медальки =) Так и закончилась НТО по информационной безопасности!

Перед тем, как мы разойдемся, пара слов о формате. Как известно, соревнования attack-defence крайне сложно организовывать (особенно на олимпиадах для школьников). Как мне кажется, авторы НТО придумали отличный формат-компромисс (сохранив ключевую идею и переформатировав хардкорную игру между всеми командами), определенно не лишенный будущего. Жалко лишь, что на решение этого тура на самой олимпиаде у нас (да и у других команд) осталось совсем мало времени – из-за структуры этапов, открывающихся поочередно (после завершения предыдущего).

Если вам нравятся мои статьи и вы хотите видеть больше контента по инфобезу – добро пожаловать на мой канал! :)

До новых встреч на просторах Хабра!

Комментарии (3)


  1. Noospheratu
    01.07.2023 07:32
    +1

    "От отца толку мало – он ушел ... обустраивать файрволл, ..."

    Обустройство фаервола, по мнению автора - бестолковая вещь, я правильно понял мысль?


    1. kgleba Автор
      01.07.2023 07:32
      -1

      Отнюдь)

      Речь про то, что в процессе закрытия уязвимостей мы предоставлены сами себе

      Сие предложение – шутка, и не предполагалось, что кто-то будет воспринимать его всерьез)


  1. Slonser
    01.07.2023 07:32
    +3

    Эх, годы идут, а качество задач на НТО становится только хуже.

    Как известно, соревнования attack-defence крайне сложно организовывать (особенно на олимпиадах для школьников).

    Для меня остается загадкой смысл данного предложения. Какие ограничение на формат AD накладывает участие в нем школьников? Если автор имеет ввиду сложность поднятие инфраструктуры участниками, то это легко парировать - выдавать вулнбоксы с поднятыми фермами и пакмейтами.
    Так же есть большие вопросы к качеству заданий, авторы взяли очень шаблонные уязвимости. Хорошее Web CTF задание - должно представлять собой мини исследование от автора. Чтобы решая данное задание участники открыли для себя что-то новое. Impact от таких заданий - околонулевой. Ну как говорится, https://cbsctf.ru/oops.