1038_CVE_EnumStringValues_ru/image1.png


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


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

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


  1. Ainyru
    00.00.0000 00:00
    +11

    Кэшировать парсинг коротких строк это... даже не знаю с чем сравнить.
    Особая одаренность нужна такого креатива.


    1. maledog
      00.00.0000 00:00
      +3

      Тут хотя бы нужно послать объём данных сравнимый с объёмом памяти сервера. А я видел эпичную ошибку http-сервера, где сервер резервировал оперативную память чтобы распарсить запрос на основании "Content-Length" из запроса. Таким образом, сервер ронялся легко и непринужденно с затратой минимума ресурсов атакующим.


      1. mentin
        00.00.0000 00:00

        Если данные передаются сжатые, то тут тоже можно обойтись очень небольшим трафиком. Скажем, несколько строк состоящих из уникального гуида и гигабайта буквы 'а', сожмутся очень хорошо.


        1. domix32
          00.00.0000 00:00

          термин zip-бомба неспроста появилась


    1. mentin
      00.00.0000 00:00
      +1

      Причем для парсинга Enum вполне можно было сделать что-то полезное, скажем использовать perfect hashing (если .net этого уже не делает) если это действительно важно по производительности. Но кеширование, особенно не найденных строк, это запредельный креатив.


  1. shai_hulud
    00.00.0000 00:00
    +2

    Зачем там кеш, если набор имён для Enum известен, либо это число, которое кешировать дороже чем парсить.

    Для Flags всё равно надо делать парсинг.


  1. CrazyElf
    00.00.0000 00:00
    +3

    Поэтому в питоне к примеру обычно используют lru_cache, с ограничением на размер, а просто кэш - это потенциальный бесконечный пожиратель памяти. В целом весёлый случай, да.


    1. Revertis
      00.00.0000 00:00
      +3

      Я вот тоже подумал, что если уж кэшируем, то хотя бы указываем размер кэша.


  1. buldo
    00.00.0000 00:00

    Не думал, что можно зарегать CVE для какой-то маленькой непонятной библиотеки.

    И зачем вообще кому-то нужна эта библиотека?


    1. SergVasiliev Автор
      00.00.0000 00:00
      +1

      И зачем вообще кому-то нужна эта библиотека?

      200К загрузок у неё откуда-то набралось.

      На самом деле меня тоже удивило отношение кол-ва загрузок пакета к кол-ву форков / звёзд проекта. Но имеем, что имеем.


    1. Volokhovskii
      00.00.0000 00:00

      И зачем вообще кому-то нужна эта библиотека?

      Напоминаю, что в этом мире существует left-pad :)