Привет! В прошлый раз я рассказывал про содержательную форензику на НТО по информационной безопасности. В этот раз хотел бы поговорить о новом и еще более интересном формате, встретившемся на этом соревновании – поиске и закрытии уязвимостей! И, хоть два этих этапа не связаны друг с другом, я советую вам сперва прочесть предыдущую статью – вся полезная информация о соревновании, кстати, тоже там.
Устраивайтесь поудобнее и готовьтесь к путешествию…
Легенда
И начнем мы, по старой доброй традиции, с легенды, которую приготовили авторы соревнования (отныне гордо именующие себя 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)
Slonser
01.07.2023 07:32+3Эх, годы идут, а качество задач на НТО становится только хуже.
Как известно, соревнования attack-defence крайне сложно организовывать (особенно на олимпиадах для школьников).
Для меня остается загадкой смысл данного предложения. Какие ограничение на формат AD накладывает участие в нем школьников? Если автор имеет ввиду сложность поднятие инфраструктуры участниками, то это легко парировать - выдавать вулнбоксы с поднятыми фермами и пакмейтами.
Так же есть большие вопросы к качеству заданий, авторы взяли очень шаблонные уязвимости. Хорошее Web CTF задание - должно представлять собой мини исследование от автора. Чтобы решая данное задание участники открыли для себя что-то новое. Impact от таких заданий - околонулевой. Ну как говорится, https://cbsctf.ru/oops.
Noospheratu
"От отца толку мало – он ушел ... обустраивать файрволл, ..."
Обустройство фаервола, по мнению автора - бестолковая вещь, я правильно понял мысль?
kgleba Автор
Отнюдь)
Речь про то, что в процессе закрытия уязвимостей мы предоставлены сами себе
Сие предложение – шутка, и не предполагалось, что кто-то будет воспринимать его всерьез)