Внедрение внешних сущностей 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;. С URLhttp://запрос делает сервер, и 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.
Пошаговый сценарий атаки
Атакующий загружает XML-документ
В нём объявлена внешняя сущность, на которую ссылается поле, возвращаемое сервером. Сущность, указывающая на file:///etc/hostname, показывает случай чтения.
Парсер обрабатывает DTD
Поскольку приложение включило загрузку DTD и раскрытие сущностей, парсер читает указанный файл или URL.
Содержимое файла становится данными
Текст попадает в элемент customer, и эндпоинт возвращает его в JSON-ответе.
Атакующий читает файлы или добирается до внутренних хостов
Так можно прочитать конфигурационные файлы, ключи и метаданные облака. В 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")}
Чек-лист по защите системы для инженеров
- Разбирайте недоверенный XML через
defusedxmlв Python, а в других языках — с отключёнными DTD: в Javadisallow-doctype-declравно true, в .NETDtdProcessing.Prohibit, в libxml2 безXML_PARSE_NOENTиXML_PARSE_DTDLOAD. - Никогда не передавайте lxml
resolve_entities=True,load_dtd=Trueилиno_network=Falseдля чужих данных и запускайте Bandit в CI (B320 отмечаетlxml.etree). - Ограничьте размер запроса (
MAX_CONTENT_LENGTH) и отклоняйте любой документ с DOCTYPE. - Считайте загружаемые файлы SVG, SAML, DOCX и XLSX XML-документами и разбирайте их с теми же усиленными настройками.
- Добавьте в CI тест, который отправляет документ с внешней сущностью на
file:///etc/hostnameи ожидает 400.