Мы используем куки, чтобы пользоваться сайтом было удобно.
Хорошо
to the top
>
>
>
Парсинг string в enum ценой в 50 Гб: ра…

Парсинг string в enum ценой в 50 Гб: разбираем уязвимость CVE-2020-36620

21 Мар 2023

В этой заметке разберём уязвимость CVE-2020-36620 и посмотрим, как NuGet-пакет для конвертации string в enum может сделать C# приложение уязвимым к DoS-атакам.

1038_CVE_EnumStringValues_ru/image1.png

Представим ситуацию: есть серверное приложение, которое взаимодействует с пользователем. В одном из сценариев приложение получает от пользователя данные в строковом представлении и конвертирует их в элементы перечисления (string -> enum).

Для преобразования строки в элемент перечисления можно воспользоваться стандартными средствами .NET:

String colorStr = GetColorFromUser();
if (Enum.TryParse(colorStr, out ConsoleColor parsedColor))
{
  // Process value...
}

А можно найти какой-нибудь NuGet-пакет и попробовать сделать то же самое с его помощью. Например, EnumStringValues.

Дух авантюризма побеждает, поэтому для конвертации строк мы берём пакет EnumStringValues 4.0.0. Восклицательный знак напротив версии пакета только подогревает наш интерес...

Посмотрим, как может выглядеть код с использованием API пакета:

static void ChangeConsoleColor()
{
  String colorStr = GetColorFromUser();

  if (colorStr.TryParseStringValueToEnum<ConsoleColor>(
        out var parsedColor))
  {
    // Change console color...
  }
  else
  {
    // Error processing
  }
}

Что здесь происходит:

  • данные, поступающие от пользователя, записываются в переменную colorStr;
  • с помощью API библиотеки EnumStringValues строка конвертируется в экземпляр перечисления ConsoleColor;
  • если конвертация проходит успешно (then-ветвь), то меняется цвет консоли, с которой работает пользователь;
  • в противном случае (else-ветвь) выдаётся сообщение об ошибке.

Строки конвертируется, приложение работает. Казалось бы здорово... но не совсем. Оказывается, что приложение может вести себя так:

1038_CVE_EnumStringValues_ru/image2.png

Ах да, у нас же пакет с "восклицательным знаком"... Попробуем разобраться, откуда такое потребление памяти. В этом нам поможет такой код:

while (true)
{
  String valueToParse = ....;
  _ = valueToParse.TryParseStringValueToEnum<ConsoleColor>(
        out var parsedValue);
}

Код бесконечно парсит строки, используя библиотеку — ничего необычного. Если valueToParse будет принимать значения строковых представлений элементов ConsoleColor ("Black", "Red" и т. п.), приложение будет вести себя ожидаемо:

1038_CVE_EnumStringValues_ru/image3.png

Проблемы начнутся, если записывать в valueToParse уникальные строки. Например, так:

String valueToParse = Guid.NewGuid().ToString();

В таком случае приложение начинает неконтролируемо потреблять память.

1038_CVE_EnumStringValues_ru/image4.png

Попробуем разобраться, в чём дело. Для этого заглянем внутрь метода TryParseStringValueToEnum<T>:

public static bool 
TryParseStringValueToEnum<TEnumType>(
  this string stringValue, 
  out TEnumType parsedValue) where TEnumType : System.Enum
{
  if (stringValue == null)
  {
    throw new ArgumentNullException(nameof(stringValue), 
                                    "Input string may not be null.");
  }

  var lowerStringValue = stringValue.ToLower();
  if (!Behaviour.UseCaching)
  {
    return TryParseStringValueToEnum_Uncached(lowerStringValue, 
                                              out parsedValue);
  }

  return TryParseStringValueToEnum_ViaCache(lowerStringValue, 
                                            out parsedValue);
}

Ага, интересно. Оказывается, что под капотом есть опция кэширования — Behaviour.UseCaching. Так как явно опцию кэширования мы не трогали, посмотрим на значение по умолчанию:

/// <summary>
/// Controls whether Caching should be used. Defaults to false.
/// </summary>
public static bool UseCaching
{
  get => useCaching;
  set { useCaching = value; if (value) { ResetCaches(); } }
}

private static bool useCaching = true;

Если верить комментарию к свойству, по умолчанию кэши выключены. На самом деле — включены (useCachingtrue).

Уже сейчас можно догадаться, в чём проблема. Но чтобы убедиться наверняка, мы опустимся в код глубже.

С полученными знаниями возвращаемся в метод TryParseStringValueToEnum. В зависимости от опции кэширования будет вызван один из двух методов — TryParseStringValueToEnum_Uncached или TryParseStringValueToEnum_ViaCache:

if (!Behaviour.UseCaching)
{
  return TryParseStringValueToEnum_Uncached(lowerStringValue, 
                                            out parsedValue);
}

return TryParseStringValueToEnum_ViaCache(lowerStringValue, 
                                          out parsedValue);

В нашем случае свойство UseCaching имеет значение true, исполнение переходит в метод TryParseStringValueToEnum_ViaCache. Ниже приведён его код, однако вникать в него не обязательно — дальше мы поэтапно разберём, как работает метод.

/// <remarks>
/// This is a little more complex than one might hope, 
/// because we also need to cache the knowledge 
/// of whether the parse succeeded or not.
/// We're doing that by storing `null`, 
/// if the answer is 'No'. And decoding that, specifically.
/// </remarks>
private static bool 
TryParseStringValueToEnum_ViaCache<TEnumType>(
  string lowerStringValue, out TEnumType parsedValue) where TEnumType 
                                                        : System.Enum
{
  var enumTypeObject = typeof(TEnumType);

  var typeAppropriateDictionary 
    = parsedEnumStringsDictionaryByType.GetOrAdd(
        enumTypeObject, 
        (x) => new ConcurrentDictionary<string, Enum>());

  var cachedValue 
    = typeAppropriateDictionary.GetOrAdd(
        lowerStringValue, 
        (str) =>
        {
          var parseSucceededForDictionary =       
                TryParseStringValueToEnum_Uncached<TEnumType>(
                  lowerStringValue, 
                  out var parsedValueForDictionary);

          return   parseSucceededForDictionary 
                 ? (Enum) parsedValueForDictionary 
                 : null;
        });

  if (cachedValue != null)
  {
    parsedValue = (TEnumType)cachedValue;
    return true;
  }
  else
  {
    parsedValue = default(TEnumType);
    return false;
  }
}

Разберём, что происходит в методе.

var enumTypeObject = typeof(TEnumType);

var typeAppropriateDictionary 
  = parsedEnumStringsDictionaryByType.GetOrAdd(
      enumTypeObject, 
      (x) => new ConcurrentDictionary<string, Enum>());

В словаре parsedEnumStringsDictionaryByType ключ — тип перечисления, с которым идёт работа, а значение — кэш строк и результатов их парсинга.

Получается такая схема кэшей:

Кэш <Тип перечисления -> Кэш <Исходная строка -> Результат парсинга>>

parsedEnumStringsDictionaryByType — статическое поле:

private static 
ConcurrentDictionary<Type, ConcurrentDictionary<string, Enum>> 
parsedEnumStringsDictionaryByType;

Таким образом, в typeAppropriateDictionary сохраняется ссылка на кэш значений для того типа перечисления, с которым идёт работа (enumTypeObject).

Дальше код парсит входную строку и сохраняет результат в typeAppropriateDictionary:

var cachedValue 
  = typeAppropriateDictionary.GetOrAdd(lowerStringValue, (str) =>
    {
      var parseSucceededForDictionary 
        = TryParseStringValueToEnum_Uncached<TEnumType>(
            lowerStringValue, 
            out var parsedValueForDictionary);

      return   parseSucceededForDictionary 
             ? (Enum) parsedValueForDictionary 
             : null;
    });

В конце метод просто возвращает флаг успешности операции и записывает результирующее значение в out-параметр:

if (cachedValue != null)
{
  parsedValue = (TEnumType)cachedValue;
  return true;
}
else
{
  parsedValue = default(TEnumType);
  return false;
}

Ключевая проблема описана в комментарии к методу: This is a little more complex than one might hope, because we also need to cache the knowledge of whether the parse succeeded or not. We're doing that by storing 'null', if the answer is 'No'. And decoding that, specifically.

Даже если входную строку распарсить не удалось, она всё равно сохранится в кэш typeAppropriateDictionary: в качестве результата парсинга будет записано значение null. Так как typeAppropriateDictionary — ссылка из словаря parsedEnumStringsDictionaryByType, хранимого статически, объекты живут между вызовами метода (что логично — на то они и кэши).

Получается вот что. Если злоумышленник может отправлять приложению уникальные строки, которые парсятся с помощью API библиотеки, у него есть возможность "заспамить" кэш со всеми вытекающими.

1038_CVE_EnumStringValues_ru/image5.png

Парсинг уникальных строк приведёт к разрастанию словаря typeAppropriateDictionary. "Заспамливание" кэша подтверждает отладчик:

1038_CVE_EnumStringValues_ru/image6.png

Проблема, которую мы только что разобрали, — уязвимость CVE-2020-36620. Дополнительная информация:

Фикс простой — парсинг входных значений убрали как таковой (ссылка на коммит).

Раньше typeAppropriateDictionary заполнялся по мере поступления данных:

  • входная строка — "Yellow", в кэш записывается пара { "yellow", ConsoleColor.Yellow };
  • входная строка — "Unknown", в кэш записывается пара { "unknown", null }
  • и т. д.

Теперь typeAppropriateDictionary заполняется заранее. Словарь изначально хранит отношения строковых представлений элементов перечисления к фактическим значениям:

1038_CVE_EnumStringValues_ru/image7.png

Входные значения в словарь не записываются — их только пытаются извлекать:

if (typeAppropriateDictionary.TryGetValue(lowerStringValue, 
                                          out var cachedValue))
  ....

После этой правки кэш перестал быть уязвим к засорению уникальными строками.

Библиотека версии 4.0.1 уже включает фикс проблемы, однако соответствующий NuGet-пакет размечен как уязвимый. Судя по всему, информация берётся из GitHub Advisory, где и написано, что безопасная версия — 4.0.2. Однако в той же записи есть ссылки на данные из NVD и vuldb, где указано, что пакет безопасен уже с версии 4.0.1, а не 4.0.2. Путаница, в общем.

Ещё интересно вот что: в коде уязвимость была закрыта в конце мая 2019, а в базах информация о ней появилась спустя 3.5 года — в конце декабря 2022.

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

С одной стороны, такая задержка понятна — у проекта 3 форка и 16 звёзд, можно отнести его к категории "личных". С другой стороны, 200К загрузок пакета в сумме — это всё-таки 200К загрузок.

**

На этом мы заканчиваем разбор уязвимости CVE-2020-36620. Если понравилось, предлагаю полистать ещё пару похожих заметок:

1. "Почему моё приложение при открытии SVG-файла отправляет сетевые запросы?" Статья о том, как NuGet-пакет для работы с изображениями может сделать приложение уязвимым к XXE-атакам.

2. "История о том, как PVS-Studio нашёл ошибку в библиотеке, используемой в... PVS-Studio". Небольшая история о том, как мы нашли ошибку в исходниках библиотеки, используемой в своём же продукте.

P.S. Ссылки на свои публикации я также выкладываю в Twitter и LinkedIn — возможно, кому-то будет удобно следить за ними там.

Популярные статьи по теме


Комментарии (0)

Следующие комментарии next comments
close comment form
close form

Заполните форму в два простых шага ниже:

Ваши контактные данные:

Шаг 1
Поздравляем! У вас есть промокод!

Тип желаемой лицензии:

Шаг 2
Team license
Enterprise license
** Нажимая на кнопку, вы даете согласие на обработку
своих персональных данных. См. Политику конфиденциальности
close form
Запросите информацию о ценах
Новая лицензия
Продление лицензии
--Выберите валюту--
USD
EUR
RUB
* Нажимая на кнопку, вы даете согласие на обработку
своих персональных данных. См. Политику конфиденциальности

close form
Бесплатная лицензия PVS‑Studio для специалистов Microsoft MVP
* Нажимая на кнопку, вы даете согласие на обработку
своих персональных данных. См. Политику конфиденциальности

close form
Для получения лицензии для вашего открытого
проекта заполните, пожалуйста, эту форму
* Нажимая на кнопку, вы даете согласие на обработку
своих персональных данных. См. Политику конфиденциальности

close form
Мне интересно попробовать плагин на:
* Нажимая на кнопку, вы даете согласие на обработку
своих персональных данных. См. Политику конфиденциальности

close form
check circle
Ваше сообщение отправлено.

Мы ответим вам на


Если вы так и не получили ответ, пожалуйста, проверьте, отфильтровано ли письмо в одну из следующих стандартных папок:

  • Промоакции
  • Оповещения
  • Спам