Вступление
Когда речь заходит о безопасности веб-приложений, одна из рекомендаций по написанию программного обеспечения, устойчивого к атакам, заключается в правильной проверке входных данных. Однако по мере роста количества мобильных приложений и API-интерфейсов (Application Programming Interface) количество ненадежных источников, откуда поступают данные, увеличивается, и потенциальный злоумышленник может воспользоваться отсутствием проверок для компрометации наших приложений.
Регулярные выражения предоставляют универсальный механизм для проверки входных данных. Разработчики используют их для проверки адресов электронной почты, почтовых индексов, телефонных номеров и многих других задач, которые легко реализуются.
К сожалению, в большинстве случаев разработчики программного обеспечения не до конца понимают, как регулярные выражения работают в фоновом режиме, и, выбрав неправильный шаблон регулярных выражений, они могут создать риск в приложении.
В этой статье мы собираемся обсудить так называемую уязвимость регулярного выражения отказа в обслуживании (ReDoS) и то, как мы можем выявить эти проблемы на ранних стадиях жизненного цикла разработки программного обеспечения (SDLC), применяя культуру, ориентированную на модульное тестирование.
Аппаратные функции для этой статьи
Чтобы предоставить информацию о времени выполнения, производительности, использовании процессора и других фактах, мы полагаемся на виртуальную машину, которая использует 32-разрядную операционную систему Windows 7, 5,22 ГБ ОЗУ. Процессор Intel® Core®TM iT-3820QM с частотой 2,7 ГГц. Мы также используем 4 ядра.
Понимание проблемы.
OWASP Foundation (2012) определяет регулярное выражение атаки типа «отказ в обслуживании» следующим образом:
«Регулярное выражение« Отказ в обслуживании »(ReDoS) представляет собой атаку« Отказ в обслуживании », в которой используется тот факт, что большинство реализаций Регулярного выражения могут достигать экстремальных ситуаций, которые заставляют их работать очень медленно (экспоненциально в зависимости от размера ввода). Затем злоумышленник может вызвать программа, использующая регулярные выражения для входа в эти экстремальные ситуации, а затем зависания в течение очень долгого времени ».
Хотя широкое объяснение механизмов регулярных выражений выходит за рамки данной статьи, важно понимать, что, согласно Stubblebine, T (Pocket Reference для регулярных выражений), сопоставление с образцом состоит из нахождения описываемого фрагмента текста ( соответствует) регулярным выражением. Два основных правила используются для сопоставления результатов:
- Самые ранние (самые левые) выигрыши: регулярное выражение применяется к вводу, начинающемуся с первого символа и перемещающемуся к последнему. Как только механизм регулярных выражений находит совпадение, он возвращается.
- Стандартные квантификаторы являются жадными: согласно Stubblebine, «Квантификаторы определяют, сколько раз что-то может повторяться. Стандартные квантификаторы пытаются совпадать столько раз, сколько возможно. Процесс отказа от символов и попытки менее жадных совпадений называется обратным отслеживанием».
В этой статье мы нацелены на механизм регулярных выражений, называемый недетерминированным конечным автоматом (NFA). Эти механизмы обычно сравнивают каждый элемент регулярного выражения с входной строкой, отслеживая позиции, в которых он выбирал между двумя опциями в регулярном выражении. Если опция не срабатывает, двигатель возвращается к последней сохраненной позиции (Stubblebine, T 2007). Важно отметить, что этот движок также реализован в .NET, Java, Python, PHP и Ruby on rails.
Эта статья посвящена C #, и поэтому мы полагаемся на классы Microsoft .NET Framework System.Text.RegularExpression, которые в своей основе используют механизмы NFA.
По словам Брайана Салливана
«Одним из важных побочных эффектов обратного отслеживания является то, что хотя механизм регулярных выражений может довольно быстро подтвердить положительное совпадение (то есть входная строка соответствует заданному регулярному выражению), подтверждение отрицательного совпадения (входная строка не соответствует регулярному выражению) может занять немного дольше. Фактически, движок должен подтвердить, что ни один из возможных «путей» через входную строку не соответствует регулярному выражению, что означает, что все пути должны быть проверены. С помощью простого не группирующего регулярного выражения время, потраченное Подтвердить отрицательные совпадения не является большой проблемой «.
Чтобы проиллюстрировать проблему, давайте использовать это регулярное выражение (\ w + \ d +) + C, которое в основном выполняет следующие проверки:
- От одного до неограниченного количества раз, столько раз, сколько возможно, отдача по мере необходимости.
- \ w + соответствует любому слову слова a-zA-Z0-9_ .
- \ d + соответствует цифре 0-9
- Соответствует символу C буквально (с учетом регистра)
Таким образом, совпадающими значениями являются 12C, 1232323232C и !!!! cD4C, а несовпадающими значениями являются, например, !!!!! C, aaaaaaC и abababababC .
Следующий модульный тест был создан для проверки обоих случаев.
const string RegExPattern = @"(\w+\d+)+C";
public void TestRegularExpression()
{
var validInput = "1234567C";
var invalidInput = "aaaaaaaC";
Regex.IsMatch(validInput, RegExPattern).assert_Is_True();
Regex.IsMatch(invalidInput, RegExPattern).assert_Is_False();
}
Execution time : 6 milliseconds
Теперь, когда мы проверили, что наше регулярное выражение работает хорошо, давайте напишем новый модульный тест, чтобы понять проблему возврата и влияние на производительность.
Обратите внимание, что чем длиннее строка, тем больше времени потребуется обработчику регулярных выражений для ее разрешения. Мы сгенерируем 10 случайных строк, начиная с длины 15 символов, увеличивая длину до 25 символов, и затем мы увидим время выполнения.
const string RegExPattern = @"(\w+\d+)+C";
[TestMethod]
public void IsValidInput()
{
var sw = new Stopwatch();
Int16 maxIterations = 25;
for (var index = 15; index < maxIterations; index++)
{
sw.Start();
//Generating x random numbers using FluentSharp API
var input = index.randomNumbers() + "!";
Regex.IsMatch(input, RegExPattern).assert_False();
sw.stop();
sw.Reset();
}
}
Теперь давайте посмотрим на результаты теста:
Случайная строка | Длина символа | Истекшее время (мс) |
---|---|---|
360817709111694! | 16 | 16мс |
2639383945572745! | 17 | 23МС |
57994905459869261! | 18 | 50мс |
327218096525942566! | 19 | 106ms |
4700367489525396856! | 20 | 207ms |
24889747040739379138! | 21 | 394ms |
156014309536784168029! | 22 | 795ms |
8797112169446577775348! | 23 | 1595ms |
+41494510101927739218368! | 24 | 3200ms |
112649159593822679584363! | 25 | 6323ms |
Посмотрев на эти результаты, мы можем понять, что время выполнения (общее время для разрешения входного текста по сравнению с регулярным выражением) экспоненциально увеличивается до размера ввода.
Мы также можем видеть, что когда мы добавляем новый символ, время выполнения почти дублируется. Это важный вывод, потому что показывает, насколько дорог этот процесс, и если у нас нет правильной проверки входных данных, мы можем вызвать проблемы с производительностью в нашем приложении.
Реальный пример использования и апелляция к подходу модульного тестирования
Теперь, когда мы увидели проблемы, с которыми мы можем столкнуться, выбрав неправильное (злое) регулярное выражение, давайте обсудим реалистичный сценарий, в котором нам нужно проверять входные данные с помощью регулярных выражений.
Мы твердо верим, что методы модульного тестирования могут не только помочь в написании качественного кода, но также мы можем использовать их, чтобы найти уязвимости в коде, который мы пишем. Путем написания модульного теста, который выполняет проверки безопасности (например, проверка входных данных)
Обычная задача в веб-приложениях состоит в том, чтобы по запросу адрес электронной почты подписывался пользователем в нашем приложении. С точки зрения UX (с точки зрения пользовательского опыта) браузеры, поддерживающие жалобы, поддерживают дружественные сообщения об ошибках, когда ввод, который должен был быть адресом электронной почты, не соответствует требованиям в отношении формата. Вот проверка пользовательского интерфейса, когда текстовое поле ввода (с типом электронной почты установлено) и значение не является действительным адресом электронной почты.
Однако полагаться на проверку пользовательского интерфейса недостаточно. Подслушиватель может легко выполнить HTTP-запрос без использования браузера (а именно с помощью прокси-сервера для захвата данных при передаче), а затем отправить полезную нагрузку, которая может поставить под угрозу наше приложение.
В следующем примере использования мы используем проверку бэкенда для адреса электронной почты с помощью регулярного выражения. Мы покажем вам реальную силу регулярных выражений здесь, мы не только проверяем, что регулярное выражение проверяет входные данные, но также и как они ведут себя, когда получают произвольные входные данные.
Мы используем это злое регулярное выражение для проверки электронной почты: ^ ( 0-9a-zA-Z @ ([0-9a-zA-Z] [- \ w] [0-9a-zA-Z].) + [ a-zA-Z] {2,9}) $ .
С помощью следующего теста мы проверяем правильность обработки действительного и недопустимого форматов электронных писем с помощью регулярного выражения, что является функциональным аспектом с точки зрения разработки.
const string EmailRegex = @"^([0-9a-zA-Z]([-.\w]*[0-9a-zA-Z])*@([0-9a-zA-Z][-\w]*[0-9a-zA-Z]\.)+[a-zA-Z]{2,9})$";
[TestMethod]
public void ValidateEmailAddress()
{
var validEmailAddress = "[email protected]";
var invalidEmailAddress = new String[] { "a", "abc.com", "1212", "aa.bb.cc", "aabcr@s" };
Regex.IsMatch(validEmailAddress, EmailRegex).assert_Is_True();
//Looping throught invalid email address
foreach (var email in invalidEmailAddress)
{
Regex.IsMatch(email, EmailRegex).assert_Is_False();
}
}
Elapsed time: 6ms.
Таким образом, оба случая подтверждаются правильно. Можно утверждать, что обоих сценариев, поддерживаемых модульным тестом, достаточно, чтобы выбрать это регулярное выражение для наших проверок входных данных. Однако, как вы увидите, мы можем провести более обширное тестирование.
Эксплойт
Пока предыдущее регулярное выражение, выбранное для проверки правильности адреса электронной почты, кажется, работает хорошо, мы добавили некоторый модульный тест, который проверяет допустимость неверных входных данных.
Но как он ведет себя, когда мы отправляем произвольный ввод? С переменной длины, сталкиваемся ли мы с отказом от сервисной атаки ?.
Этот тип вопросов может быть решен с помощью методики модульного тестирования, как этот:
const string EmailRegex = @"^([0-9a-zA-Z]([-.\w]*[0-9a-zA-Z])*@([0-9a-zA-Z][-\w]*[0-9a-zA-Z]\.)+[a-zA-Z]{2,9})$";
[TestMethod]
public void ValidateEmailAddress()
{
var validEmailAddress = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!";
var watch = new Stopwatch();
watch.Start();
validEmailAddress.regEx(EmailRegex).assert_Is_False();
watch.Stop();
Console.WriteLine("Elapsed time {0}ms", watch.ElapsedMilliseconds);
watch.Reset();
}
**Elapsed Time : ~23 minutes (1423127 milliseconds).**
Результаты вызывают беспокойство. Мы можем ясно увидеть проблему с производительностью, представленную путем оценки заданного ввода. Требуется примерно 23 минуты для проверки ввода с учетом характеристик оборудования, описанных ранее.
На следующих изображениях вы увидите поведение процессора при запуске этого модульного теста.
Вот еще одна версия процессора:
И это еще один образ из загрузки процессора во время выполнения теста.
Fuzzing и Unit-тестирование: идеальное сочетание методов
В предыдущем модульном тесте мы обнаружили, что данная входная строка может привести к отказу в обслуживании в нашем приложении. Обратите внимание, что нам не требовалась чрезвычайно большая полезная нагрузка, в нашем сценарии 34 символа могут иллюстрировать эту проблему или даже меньше. При использовании любого регулярного выражения рекомендуется всегда проверять его на предмет юнит-тестирования, чтобы охватить большинство возможных способов, которые может отправить пользователь (который может быть потенциальным злоумышленником).
Здесь мы можем использовать Fuzzing.
Тобиас Кляйн в своей книге «Дневник охотника за жуками». «Путеводитель по дикой природе безопасности программного обеспечения» определяет Fuzzing как
«Совершенно другой подход к поиску ошибок известен как фаззинг. Фаззинг — это метод динамического анализа, который заключается в тестировании приложения путем предоставления ему некорректных или неожиданных входных данных.
Затем Кляйн продолжает добавлять, что:
«Нелегко определить точки входа в такие сложные приложения, но сложное программное обеспечение часто приводит к сбою при обработке искаженных входных данных».
Мано Пол в своей книге « Официальное руководство (ISC) 2» О том, как CSSLP говорит о Fuzzing, гласит:
«Также известный как нечеткое тестирование или тестирование с инжекцией неисправностей, фаззинг — это тип тестирования методом грубой силы, при котором ошибки (случайные и псевдослучайные входные данные) вводятся в программное обеспечение и наблюдается его поведение. Это тест, результаты которого свидетельствует о расширении и эффективности проверки ввода. Страница 336 «.
Принимая во внимание предыдущие определения, мы собираемся реализовать новый модульный тест, который может позволить нам генерировать случайные входные данные и проверять наше регулярное выражение.
В этом случае мы используем это регулярное выражение электронной почты «^ [\ w -.] {1,} \ @ ([\ w] {1,}.) {1,} [az] {2,4} $» ; и проведя исчерпывающее тестирование, мы увидим, не вводим ли мы проблему отказа в обслуживании.
Мы хотим убедиться, что истекшее время для разрешения, если случайная строка соответствует регулярному выражению, оценивается менее чем за 3 секунды:
const string EmailRegex = @"^[\w-\.]{1,}\@([\w]{1,}\.){1,}[a-z]{2,4}$";
//Number of random strings to generte.
const int maxIterations = 10000;
[TestMethod]
public void Fuzz_EmailAddress()
{
//Valid email should return true
"[email protected]".regEx(EmailRegex).assert_Is_True();
//Invalid email should return false
"abce" .regEx(EmailRegex).assert_Is_False();
//Testing maxIterations times
for (int index = 0; index < maxIterations; index++)
{
//Generating a random string
var fuzzInput = (index * 5).randomString();
var sw = new Stopwatch();
sw.Start();
fuzzInput.regEx(EmailRegex).assert_Is_False();
//Elapsed time should be less than 3 seconds per input.
sw.Elapsed.seconds().assert_Size_Is_Smaller_Than(3);
}
}
В соответствии с аппаратными функциями, описанными ранее, этот тест проходит. Учитывая, что мы используем это вычисление (индекс * 5), наибольшая генерируемая строка имеет 49995 символов (что составляет 9999 * 5).
Сказав, что мы смогли протестировать большую строку с регулярным выражением, мы подтвердили, что даже при том, что это довольно большое входное значение, время, затрачиваемое на проверку того, было ли это действительное электронное письмо или нет, было меньше 3 секунд.
Теперь, предполагая, что проверка длины электронной почты в первую очередь, это гарантирует, что злоумышленник не сможет внедрить большую полезную нагрузку в наше приложение.
Контрмеры в Microsoft .NET 4.5 и выше
Если вы разрабатываете приложения в Microsoft .NET 4.5, вы можете воспользоваться новой реализацией поверх метода IsMatch из класса Regex . Начиная с .NET 4.5 метод IsMatch обеспечивает перегрузку, которая позволяет вам ввести тайм-аут. Обратите внимание, что эта перегрузка недоступна в .NET 4.0 .
Этот новый параметр называется matchTimeout и в соответствии с Microsoft:
«Параметр matchTimeout указывает, как долго метод сопоставления с образцом должен пытаться найти совпадение, прежде чем истечет время ожидания. Установка интервала времени ожидания не позволяет регулярным выражениям, основанным на чрезмерном обратном отслеживании, перестать отвечать, когда они обрабатывают ввод, содержащий близкие совпадения. Дополнительные сведения см . В разделах «Рекомендации для регулярных выражений в .NET Framework» и « Возврат в регулярных выражениях» . Если в этом временном интервале совпадение не найдено, метод генерирует исключение RegexMatchTimeoutException. MatchTimeout переопределяет любое значение времени ожидания по умолчанию, определенное для приложения. домен, в котором выполняется метод. » Взято отсюда .
Мы написали новый модульный тест, в котором мы используем регулярное выражение, которое, как мы знаем, может привести к отказу в обслуживании. В этом случае мы протестируем адрес электронной почты, который ранее вызывал значительный побочный эффект в производительности приложения. Тогда мы увидим, как мы можем уменьшить влияние этого процесса, установив таймаут.
const string EmailRegexPattern = @"^([0-9a-zA-Z]([-.\w]*[0-9a-zA-Z])*@([0-9a-zA-Z][-\w]*[0-9a-zA-Z]\.)+[a-zA-Z]{2,9})$";
[TestMethod]
public void ValidateEmailAddress()
{
var emailAddress = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!";
var watch = new Stopwatch();
watch.Start();
//Timeout of 5 seconds
try
{
Regex.IsMatch(emailAddress, EmailRegexPattern,
RegexOptions.IgnoreCase,
TimeSpan.FromSeconds(5));
}
catch (Exception ex)
{
ex.Message.assert_Not_Null();
ex.GetType().assert_Is(typeof(RegexMatchTimeoutException));
}
finally
{
watch.Stop();
watch.Elapsed.seconds().assert_Size_Is_Smaller_Than(5);
watch.Reset();
}
}
Запустив этот тест в Visual Studio, мы можем подтвердить, что он пройден, а это означает, что для механизма возврата требуется более 5 секунд.
Он выдаст исключение RegexMatchTimeoutException, указывающее, что для оценки ввода может потребоваться более 5 секунд. В идеале можно ожидать, что этот процесс займет менее секунды, однако некоторые условия или требования могут привести к превышению времени ожидания в секундах.
Обратите внимание, что эта модель обеспечивает очень необходимый стиль защитного программирования, когда инженеры-программисты принимают обоснованные решения относительно кода, который они пишут. В этом случае мы можем установить следующие шаги, когда наш метод истечет, и таким образом мы сможем уменьшить любой отказ в обслуживании.
Последние мысли
Ни один размер не подходит всем настолько клише, что должно быть правдой. Мы не уверены, что регулярные выражения, которые вы используете в своих приложениях, уязвимы для этой атаки. Что мы можем сделать наверняка, так это показать вам, как вы можете использовать преимущества модульного тестирования для написания безопасного кода.
Когда мы пишем код, мы хотим убедиться, что каждая отдельная строка кода покрыта модульным тестированием, которое в конце дня гарантирует раннее обнаружение ошибки. Однако, если мы сможем объединить это упражнение с принятием и реализацией теста, который также может попытаться атаковать / скомпрометировать приложение (и мы не говорим ни о чем причудливом), как отправка случайных строк, использование методов фаззинга, использование комбинации символов, превышающих Ожидаемая длина, мы будем помогать в написании программного обеспечения, которое устойчиво к атакам.
В качестве рекомендации всегда проверяйте свои регулярные выражения против универсального теста, убедитесь, что они устойчивы к атаке, о которой мы рассказывали в этой статье, и если вы в состоянии выявить эти проблемные шаблоны, внесите свой вклад и сообщите о них, чтобы мы не представить их в программном обеспечении, которое мы пишем.
Рекомендации
1.Cruz, Dinis (2013). Регулярная рассылка по электронной почте, которая (могла иметь) создала сайт.
2.Hollos, S. Холлос, р (2013) Проблемы конечных автоматов и регулярных выражений и решения.
3.Kirrage, J. Rathnayake, Thielecke, H .: Статический анализ для атак типа «отказ в обслуживании» с регулярными выражениями. Бирмингемский университет, Великобритания
4. Клейн, Т. Жук Дневник Охотника. Экскурсия по дикой природе безопасности программного обеспечения (2011).
5. Фонд OWASP (2012) Регулярное выражение Отказ в обслуживании — ReDoS.
6.Stubblebine, T (2007) Справочник по регулярным выражениям, второе издание.
7.Салливан, Б. (2010). Регулярные выражения: отказ в обслуживании и защита