В этой заметке разберём уязвимость 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
}
}
Что здесь происходит:
Строки конвертируется, приложение работает. Казалось бы здорово... но не совсем. Оказывается, что приложение может вести себя так:
Ах да, у нас же пакет с "восклицательным знаком"... Попробуем разобраться, откуда такое потребление памяти. В этом нам поможет такой код:
while (true)
{
String valueToParse = ....;
_ = valueToParse.TryParseStringValueToEnum<ConsoleColor>(
out var parsedValue);
}
Код бесконечно парсит строки, используя библиотеку — ничего необычного. Если valueToParse будет принимать значения строковых представлений элементов ConsoleColor ("Black", "Red" и т. п.), приложение будет вести себя ожидаемо:
Проблемы начнутся, если записывать в valueToParse уникальные строки. Например, так:
String valueToParse = Guid.NewGuid().ToString();
В таком случае приложение начинает неконтролируемо потреблять память.
Попробуем разобраться, в чём дело. Для этого заглянем внутрь метода 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;
Если верить комментарию к свойству, по умолчанию кэши выключены. На самом деле — включены (useCaching — true).
Уже сейчас можно догадаться, в чём проблема. Но чтобы убедиться наверняка, мы опустимся в код глубже.
С полученными знаниями возвращаемся в метод 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 библиотеки, у него есть возможность "заспамить" кэш со всеми вытекающими.
Парсинг уникальных строк приведёт к разрастанию словаря typeAppropriateDictionary. "Заспамливание" кэша подтверждает отладчик:
Проблема, которую мы только что разобрали, — уязвимость CVE-2020-36620. Дополнительная информация:
Фикс простой — парсинг входных значений убрали как таковой (ссылка на коммит).
Раньше typeAppropriateDictionary заполнялся по мере поступления данных:
Теперь typeAppropriateDictionary заполняется заранее. Словарь изначально хранит отношения строковых представлений элементов перечисления к фактическим значениям:
Входные значения в словарь не записываются — их только пытаются извлекать:
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 — возможно, кому-то будет удобно следить за ними там.