Уязвимость XXE в .NET 6 SDK: с чем боролись…

zpfukkzo_dmhyngtl1ig9vpal3k.png

Современный .NET даёт разработчикам защиту от XXE из коробки: парсишь себе XML и не забиваешь голову всякими DTD, сущностями и связанной с ними безопасностью. Разве не прекрасно? Однако жизнь — штука с иронией…

Под катом — разбор по кусочкам XXE из .NET 6 SDK: код, причины дефекта безопасности, фикс.

Примечание. Я писал статью с расчётом на читателя, уже знакомого с XXE. Если только знакомитесь с темой или нужно освежить память, предлагаю эти материалы:


XXE в .NET: специфика XmlDocument

XML-парсеры с дефолтными настройками в современном .NET в основном защищены от XXE. Это достигается за счёт выключения резолверов сущностей или отключения обработки DTD — зависит от конкретного парсера.

Почему в основном?


  • не возьмусь сказать наверняка за все парсеры;
  • если бы все парсеры были защищены, то и статьи не было бы :)

Чтобы разобраться с уязвимостью из .NET 6, вспомним специфику типа XmlDocument. Начнём с примера. Такой код в современном .NET защищён от XXE:

XmlDocument xmlDoc = new XmlDocument();
xmlDoc.Load(xmlStream);
// Processing...

Убедимся в этом — попробуем прочитать XML-парсером локальный файл и распечатать содержимое в консоль:

static void ProcessXml(Stream xmlStream)
{
    var xmlDoc = new XmlDocument();       
    xmlDoc.Load(xmlStream);

    // Processing...
    Console.WriteLine(xmlDoc.InnerText);
}

Вредоносный XML:



]>

    &query;

Результат — пустой выхлоп.

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

Приведённый выше код легко сделать опасным, проинициализировав свойство XmlResolver:

static void ProcessXml(Stream xmlStream)
{
    XmlDocument xmlDoc = new XmlDocument()
    {
        XmlResolver = new XmlUrlResolver()
    };

    xmlDoc.Load(xmlStream);

    // Processing...
    Console.WriteLine(xmlDoc.InnerText);
}

Если этот код будет парсить тот же XML, приложение запишет в консоль содержимое файла hosts:

_gmycdmgqkorx140bwqgpiraxgo.png

С сетевыми запросами ситуация аналогична — меняем содержимое подаваемого на вход XML-файла и проверяем указанную в нём конечную точку.

XML с сущностью обращения к внешнему ресурсу (URI сокращён):



]>
&query;

Пойманный сетевой запрос:

vnzvbm-rz_kqwhotkgheleczufs.png

Вывод: в .NET экземпляры XmlDocument безопасны из коробки, так как у них отсутствует резолвер. Однако парсер станет уязвимым, если явно проинициализировать свойство XmlResolver опасным значением (например, экземпляром XmlUrlResolver в дефолтном состоянии).


А вот в случае с .NET Framework всё не так радужно.

Безопасность дефолтных парсеров в .NET Framework зависит не только от версии фреймворка, но и от ряда других факторов. Подробнее эту тему я разбирал в докладе «Уязвимости при работе с XML в .NET: часть 2» на DotNext 2023. Запись уже можно посмотреть, если есть билет.


CVE-2022–34716: XXE в .NET 6 SDK


Общая информация

От общей теории переходим к нашей основной теме — уязвимости CVE-2022–34716.
Обычно Microsoft не даёт много информации об уязвимостях в своих продуктах. Этот раз исключением не стал. С одной стороны, мотивация таких решений понятна. С другой, факт остаётся фактом: хочешь деталей — ищи их сам.

1g1x4azv3u5slm-uikqoteqjtwu.png

Основная информация:

Однако, если покопаться в интернете чуть побольше, можно найти интересные подробности: link #1, link #2. Из них выясняем, что CVE-2022–34716 — это XXE, связанная с типом System.Security.Cryptography.Xml.SignedXml. Что ж, давайте попробуем составить PoC и найти причины дефекта безопасности.

Чтобы изучить проблему, соберём тестовый проект на .NET 6 SDK с уязвимой версией пакета System.Security.Cryptography.Xml — 6.0.0. Код для работы с типом SignedXml возьмём из документации.

Сокращённый вариант кода из доков, достаточный для исследования:

void ProcessSignedXml(String xmlPath) 
{       
    var xmlDoc = new XmlDocument();       
    xmlDoc.Load(xmlPath);      

    var signedXml = new SignedXml(xmlDoc);       
    signedXml.SigningKey = RSA.Create();       

    Reference reference = new Reference();       
    reference.Uri = String.Empty;       

    var env = new XmlDsigEnvelopedSignatureTransform();       
    reference.AddTransform(env);       

    signedXml.AddReference(reference);       

    signedXml.ComputeSignature();
    // ...
}

На вход подаём XML-файл следующего вида:



]>
&query;

Вместо path/to/endpoint я использовал конкретный эндпоинт на beeceptor.com. Если при парсинге XML-файла на конечную придёт запрос, значит, мы докопались до XXE.

Алгоритм проверки получается таким:


  1. Отдаём описанный выше XML-файл в метод ProcessSignedXml.
  2. Отлаживаем код и смотрим, какое обращение к API приводит к пингу конечной точки.
  3. Раскручиваем обращение к API до выяснения причин.

Вернёмся к методу ProcessSignedXml:

void ProcessSignedXml(String xmlPath) 
{       
    var xmlDoc = new XmlDocument();       
    xmlDoc.Load(xmlPath);      

    var signedXml = new SignedXml(xmlDoc);       
    signedXml.SigningKey = RSA.Create();       

    Reference reference = new Reference();       
    reference.Uri = String.Empty;       

    var env = new XmlDsigEnvelopedSignatureTransform();       
    reference.AddTransform(env);       

    signedXml.AddReference(reference);       

    signedXml.ComputeSignature();    
    // ...
}

Первое, что может вызвать подозрение — вызов метода XmlDocument.Load:

var xmlDoc = new XmlDocument();       
xmlDoc.Load(xmlPath);    

Однако мы разобрались, что подобный код в .NET безопасен. К тому же он не задействует API SignedXml.

Создание экземпляра SignedXml также не порождает сетевого запроса:

var signedXml = new SignedXml(xmlDoc);

Не буду томить — обращение к конечной точке происходит во время вызова метода ComputeSignature. Неожиданно…

На самом деле нас интересует даже не ComputeSignature, а транзитивно вызываемый им CalculateHashValue. Цепочка вызовов выглядит так:

ComputeSignature
  -> BuildDigestReferences
       -> UpdateHashValue
            -> CalculateHashValue

Что ж, давайте посмотрим на CalculateHashValue.


Анализ метода CalculateHashValue

Метод занимает порядка 150 строк, поэтому мы разберём только маленький его фрагмент — тот, в который переходит исполнение в нашем случае:

internal byte[] 
CalculateHashValue(XmlDocument document, CanonicalXmlNodeList refList)
{
  ...
  XmlResolver resolver = null;
  ...
  resolver = (SignedXml.ResolverSet ? SignedXml._xmlResolver 
                                    : new XmlSecureResolver(new XmlUrlResolver(),
                                                            baseUri));

  XmlDocument docWithNoComments = Utils.DiscardComments(
    Utils.PreProcessDocumentInput(document, resolver, baseUri));
  ...
}

Ага, интересно… В глаза бросаются сразу несколько моментов.

Первый — присутствие переменной resolver типа XmlResolver. В начале статьи мы разбирали, что использование опасных резолверов (например, XmlUrlResolver) может сделать XML-парсер уязвимым к XXE.

Второй — проинициализированный резолвер передаётся ещё глубже — в метод Utils.PreProcessDocumentInput. Именно при его вызове и выполняется сетевой запрос.

Разберём оба этих момента.

Примечание. Рассматриваемая ветка кода — не единственная, где создаётся и используется резолвер. Если интересно посмотреть на остальные, загляните в исходники.


XmlSecureResolver

Код объявления и инициализации резолвера:

XmlResolver resolver = null;
...
resolver = (SignedXml.ResolverSet ? SignedXml._xmlResolver 
                                  : new XmlSecureResolver(new XmlUrlResolver(),
                                                          baseUri));

До этого момента резолвер выставлен не был, поэтому свойство SignedXml.ResolverSet имеет значение false. Следовательно, resolver инициализируется ссылкой на экземпляр XmlSecureResolver, созданный в alternative-ветви тернарного оператора.

Обратите внимание, что первым аргументом конструктора XmlSecureResolver выступает ссылка на экземпляр XmlUrlResolver в дефолтном состоянии. Мы уже знаем, что такие резолверы опасны. Но может внутри XmlSecureResolver есть какая-то защита? Давайте проверим:

public partial class XmlSecureResolver : XmlResolver
{
    private readonly XmlResolver _resolver;

    public XmlSecureResolver(XmlResolver resolver, string? securityUrl)
    {
        _resolver = resolver;
    }

    public override ICredentials Credentials
    {
        set { _resolver.Credentials = value; }
    }

    public override object? GetEntity(Uri absoluteUri, 
                                      string? role, Type? ofObjectToReturn)
    {
        return _resolver.GetEntity(absoluteUri, role, ofObjectToReturn);
    }

    public override Uri ResolveUri(Uri? baseUri, string? relativeUri)
    {
        return _resolver.ResolveUri(baseUri, relativeUri);
    }
}

Нет, ничего. Методы, отвечающие за резолвинг URI и обработку сущностей по факту делегируют работу объекту, на который ссылается поле _resolver. Чем оно проинициализировано? Правильно — ссылкой на опасный резолвер, который был передан в конструктор:

new XmlSecureResolver(new XmlUrlResolver(), baseUri)

Вывод: в плане работы с сущностями XmlSecureResolver так же опасен, как и XmlUrlResolver.


Utils.PreProcessDocumentInput

Вызов метода Utils.PreProcessDocumentInput выглядит так:

XmlDocument docWithNoComments = Utils.DiscardComments(
      Utils.PreProcessDocumentInput(document, resolver, baseUri));

Мы выяснили, что resolver ссылается на опасный объект. Посмотрим, что происходит внутри PreProcessDocumentInput:

internal static XmlDocument 
PreProcessDocumentInput(XmlDocument document, 
                        XmlResolver xmlResolver, 
                        string baseUri)
{
    if (document == null)
        throw new ArgumentNullException(nameof(document));

    MyXmlDocument doc = new MyXmlDocument();
    doc.PreserveWhitespace = document.PreserveWhitespace;

    // Normalize the document
    using (TextReader stringReader = new StringReader(document.OuterXml))
    {
        XmlReaderSettings settings = new XmlReaderSettings();
        settings.XmlResolver = xmlResolver;
        settings.DtdProcessing = DtdProcessing.Parse;
        ...
        XmlReader reader = XmlReader.Create(stringReader, settings, baseUri);
        doc.Load(reader);
    }
    return doc;
}

Основное, что нас интересует — создание XML-парсера (reader) на основе настроек (settings), причём:


  1. Свойство DtdProcessing инициализируется значением DtdProcessing.Parse.
  2. В свойство XmlResolver записывается ссылка на опасный резолвер — экземпляр XmlSecureResolver, с которым мы разбирались выше.

Всё это делает созданный экземпляр XmlReader уязвимым к XXE-атакам. Поэтому же вызов doc.Load(reader) может читать локальные файлы или порождать сетевые запросы, что и происходит в нашем случае.


Саммари

Соберём основные моменты, которые привели к уязвимости:


  1. При использовании API SignedXml мы неявно вызвали метод CalculateHashValue.
  2. Метод CalculateHashValue, в свою очередь, вызывает вспомогательный метод Utils.PreProcessDocumentInput, в который передаёт ссылку на экземпляр типа XmlSecureResolver.
  3. Тип XmlSecureResolver делегирует обработку внешних сущностей экземпляру типа XmlUrlResolver и из-за этого является опасным.
  4. В методе Utils.PreProcessDocumentInput создаётся XML-парсер типа XmlReader, который:
    • разбирает DTD;
    • использует в качестве резолвера экземпляр XmlSecureResolver.
  5. Из-за перечисленных свойств созданный парсер является уязвимым к XXE.
  6. Так как этот парсер разбирает вредоносный XML, возникает уязвимость.

Напомню, что мы обрабатывали с помощью SignedXml API файл такого вида:



]>
&query;

Настроим конечную точку на возврат текста: тогда после вызова doc.Load(reader) сможем прочитать его, обратившись к свойству doc.InnerText:

lifb2rrcq9cwxyxmuqdxdtre-ug.png

Игры с цитатами из Хагакурэ — забавный эксперимент. Однако напомню, что последствиями XXE могут быть не безобидные шутки, а SSRF и утечки данных.

**
Уверен, есть и другие способы провести XXE-атаку на SignedXml: в одном только методе CalculateHashValue целых 6 мест создания и использования опасных резолверов.


Фикс

С уязвимостью разобрались, посмотрим на фикс. Он достаточно интересный — не затрагивает код создания и использования резолверов.

Продублирую код, который мы разбирали:

resolver = (SignedXml.ResolverSet ? SignedXml._xmlResolver 
                                  : new XmlSecureResolver(new XmlUrlResolver(),
                                                          baseUri));

XmlDocument docWithNoComments = Utils.DiscardComments(
    Utils.PreProcessDocumentInput(document, resolver, baseUri));

Ни он, ни внутренности метода PreProcessDocumentInput не поменялись. Основное, что изменилось — тип XmlSecureResolver. Причём даже не его реализация — приведённый фрагмент кода стал использовать в принципе другой тип. Как так? Сейчас разберёмся.

Метод CalculateHashValue определён в типе Reference из пространства имён System.Security.Cryptography.Xml. Тип XmlSecureResolver находится в пространстве имён System.Xml и в область видимости для Reference включается через using:

// Reference.cs
using System.Xml;
...
namespace System.Security.Cryptography.Xml
{
    public class Reference
    {
        ...
        internal byte[] 
        CalculateHashValue(XmlDocument document, CanonicalXmlNodeList refList)
        {
            ...
            resolver = (  SignedXml.ResolverSet 
                        ? SignedXml._xmlResolver 
                        : new XmlSecureResolver(new XmlUrlResolver(),
                                                baseUri));

            XmlDocument docWithNoComments = 
              Utils.DiscardComments(
                Utils.PreProcessDocumentInput(document, resolver, baseUri));
            ...
         }
    }
}

// XmlSecureResolver.cs
namespace System.Xml
{
    ...
    public partial class XmlSecureResolver : XmlResolver
    { ... }
}

В коммите с фиксом добавляют другую реализацию типа XmlSecureResolver в рамках пространства имён System.Security.Cryptography.Xml — того же самого, где содержится и сам тип Reference.

Получается, что код типа Reference, создания и использования резолверов не поменялись. Однако теперь используются безопасные резолверы из пространства имён System.Security.Cryptography.Xml.XmlSecureResolver, а не System.Xml.XmlSecureResolver.

wdmtzjb8tjez1_g-8nc9ldlgvem.png

Сам новый резолвер выглядит так:

namespace System.Security.Cryptography.Xml
{
    // This type masks out System.Xml.XmlSecureResolver by being in the local namespace.
    internal sealed class XmlSecureResolver : XmlResolver
    {
        internal XmlSecureResolver(XmlResolver resolver, string securityUrl)
        {
        }

        // Simulate .NET Framework's CAS behavior by throwing SecurityException.
        // Unlike .NET Framework's implementation, the securityUrl ctor parameter has no effect.
        public override object 
        GetEntity(Uri absoluteUri, string role, Type ofObjectToReturn) 
          => throw new SecurityException();
    }
}

Никакого делегирования резолвинга сущностей: если метод GetEntity вызывается, то он просто выкидывает исключение типа SecurityException. В этом можно убедиться, обновив пакет System.Security.Cryptography.Xml до версии 6.0.1. Если взять проверочный код из начала раздела и скормить ему тот же вредоносный XML, вместо раскрытия сущностей получим исключение:

y28nu7t1n7tkzcde6wkoiufjp3u.png


Выводы

В .NET из коробки есть защита от XXE. Как мы сегодня убедились, эта же защита легко ломается, когда из-за стечения обстоятельств XML-парсеры получают опасные настройки.

Что здесь можно посоветовать:


  • следите за тем, чтобы парсеры не обрабатывали DTD / внешние сущности или делали это с необходимыми ограничениями;
  • будьте аккуратнее со сторонними компонентами (будь то SDK или NuGet-пакет). Если они работают с XML, кто знает, безопасно ли.

Да пребудет с вами безопасность.


Дополнительные материалы

Статьи

Доклады

P.S. На конференции Joker 2023 я рассказывал о специфике XXE в Java. Если интересно, в чём отличие от .NET или хочется поделиться со знакомыми Java-разработчиками, вот ссылка на доклад (чтобы посмотреть запись, нужен билет).

© Habrahabr.ru