● CWE-611 · OWASP A05:2021

Внедрение внешних сущностей XML (XXE, CWE-611): как работает и как предотвратить

XML-парсер, который учитывает DTD документа, загружает сущности SYSTEM по URL file:// или http:// и вставляет результат в разобранные данные, поэтому загруженный документ может читать файлы сервера или заставлять сервер обращаться к внутренним адресам. Как работают внешние сущности и раскрытие сущностей, какие настройки парсера к этому приводят и как defusedxml с forbid_dtd=True это отключает, на примере Flask.

Простыми словами (ELI5)

Типография заполняет письма по шаблону. В письме клиента написано: «Уважаемый [вставьте сюда содержимое ящика 4 вашей картотеки]». Аккуратный печатник вставляет только слова, которые дал клиент. Небрежный идёт к ящику 4, где лежат договоры других клиентов, и печатает их в письмо, которое отправляет обратно по почте. XXE — это небрежный печатник: XML-документ велит парсеру сходить за файлом или веб-адресом и вставить его, и парсер это делает. Исправление — отклонять любые письма, где есть указания «вставить откуда-то ещё».

Ключевые понятия и термины

DTD и сущности
Document Type Definition в <!DOCTYPE ...> может объявлять сущности — именованные фрагменты текста, которые парсер подставляет везде, где встречается &имя;. Внутренние сущности содержат сам текст, внешние указывают куда-то ещё.
Внешняя сущность
<!ENTITY x SYSTEM "file:///etc/hostname"> велит парсеру прочитать этот URL и вставить содержимое туда, где используется &x;. С URL http:// запрос делает сервер, и XXE превращается в подделку запросов на стороне сервера.
Раскрытие сущностей (Billion Laughs)
Сущности, каждая из которых по десять раз ссылается на другие, превращают документ в килобайт в гигабайты текста в памяти (CWE-776). Внешний доступ для этого не нужен, поэтому помогает только отказ от DTD или ограничение раскрытия.
Настройки парсера по умолчанию
Поведение зависит от библиотеки: lxml 5 по умолчанию раскрывает только внутренние сущности, но resolve_entities=True и load_dtd=True снова открывают дыру; DocumentBuilderFactory в Java раскрывает внешние сущности, если не задан disallow-doctype-decl; в .NET нужен DtdProcessing.Prohibit.
defusedxml
Пакет Python, который оборачивает стандартные парсеры и вместо обработки опасных конструкций выбрасывает EntitiesForbidden, ExternalReferenceForbidden или DTDForbidden. forbid_dtd=True отклоняет любой документ с DOCTYPE.

Пошаговый сценарий атаки

Шаг 1

Атакующий загружает XML-документ

В нём объявлена внешняя сущность, на которую ссылается поле, возвращаемое сервером. Сущность, указывающая на file:///etc/hostname, показывает случай чтения.

Шаг 2

Парсер обрабатывает DTD

Поскольку приложение включило загрузку DTD и раскрытие сущностей, парсер читает указанный файл или URL.

Шаг 3

Содержимое файла становится данными

Текст попадает в элемент customer, и эндпоинт возвращает его в JSON-ответе.

Шаг 4

Атакующий читает файлы или добирается до внутренних хостов

Так можно прочитать конфигурационные файлы, ключи и метаданные облака. В 2014 году Реджиналдо Силва получил от Facebook вознаграждение 33 500 долларов за XXE в обработке OpenID, позволявшую читать файлы на серверах.

Исходный код: Уязвимый vs Защищённый вариант

УЯЗВИМАЯ РЕАЛИЗАЦИЯ
# invoices.py: парсер выполняет всё, что требует загруженный DTD
from flask import Flask, request
from lxml import etree

app = Flask(__name__)


# сущности раскрываются, DTD загружаются, доступ к сети разрешён
@app.post("/invoices/import")
def import_invoice():
    parser = etree.XMLParser(resolve_entities=True, load_dtd=True, no_network=False)
    root = etree.fromstring(request.get_data(), parser)
    return {"customer": root.findtext("customer"), "total": root.findtext("total")}
БЕЗОПАСНЫЙ ИСПРАВЛЕННЫЙ ВАРИАНТ
# invoices.py: документы с DTD отклоняются до разбора
from defusedxml import DefusedXmlException
from defusedxml.ElementTree import ParseError, fromstring
from flask import Flask, abort, request

app = Flask(__name__)
app.config["MAX_CONTENT_LENGTH"] = 1_000_000


# без DOCTYPE, без сущностей, без внешних ссылок; тело не больше 1 МБ
@app.post("/invoices/import")
def import_invoice():
    try:
        root = fromstring(request.get_data(), forbid_dtd=True)
    except (DefusedXmlException, ParseError):
        abort(400)
    return {"customer": root.findtext("customer"), "total": root.findtext("total")}

Чек-лист по защите системы для инженеров

Источники

← Весь каталог по безопасности Все руководства по уязвимостям →