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