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

от автора

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 — возможно, кому-то будет удобно следить за ними там.


ссылка на оригинал статьи https://habr.com/ru/company/pvs-studio/blog/723776/