← назад к разделу

Маркетплейс принимает от продавцов XML: фиды каталога, накладные от поставщиков, SAML-ответы корпоративного входа. А между сервисами и в cookie корзины гоняет объекты в «родном» формате языка, потому что так быстрее написать. Обе привычки ломаются одинаково: разбор чужих байтов делает больше, чем просто читает данные. XML-парсер по указанию документа открывает файлы и ходит по сети, а десериализатор создаёт объекты и запускает их код ещё до того, как приложение посмотрело, что пришло.

Статья в две части. Сначала XXE: как устроены сущности XML, почему внешняя сущность превращает парсер в читалку файлов и HTTP-клиента, и как закрыть это настройкой парсера. Потом десериализация: что значит «нативный формат», почему цепочка гаджетов выполняет код, хотя ни одного класса атакующий не писал, почему JSON не спасает автоматически и как работают фильтры JEP 290. В конце проверки, типичные ошибки и отличия Go.

Обязательно

Как устроена атака и чем грозитспросят на собеседовании

XXE. В XML есть DTD: блок <!DOCTYPE ...> в начале документа, где объявляют сущности. Внешняя сущность берёт значение по адресу: <!ENTITY xxe SYSTEM "file:///etc/passwd">. Если парсер её разрешает, то &xxe; в теле документа заменяется содержимым файла. Это CWE-611 «Improper Restriction of XML External Entity Reference». Шпаргалка OWASP называет три последствия: «expose local files, cause server-side request forgery (SSRF), or exhaust resources». В OWASP Top 10:2025 XXE входит в A02:2025 Security Misconfiguration вместе с CWE-776 (рекурсивные сущности, «billion laughs»).

На маркетплейсе это выглядит так:

  • Чтение файлов. Продавец загружает фид, где в названии товара стоит &xxe; со ссылкой на файл конфигурации. Импорт кладёт пароль от базы в карточку товара, и продавец видит его в своём кабинете.
  • SSRF. SYSTEM "http://169.254.169.254/latest/meta-data/" заставляет сервер импорта сходить во внутреннюю сеть или к метаданным облака. Подробно про этот класс в статье SSRF.
  • Отказ в обслуживании. Вложенные внутренние сущности раздувают сотни байт в гигабайты текста, и сервис импорта съедает память.

Небезопасная десериализация. У языков есть свои форматы сериализации объектов: ObjectInputStream у Java, pickle у Python. Они восстанавливают не данные, а объекты произвольных классов, и вызывают при этом их код: readObject, __reduce__. Атакующий не может прислать свой код, но может собрать из классов, которые уже лежат в зависимостях приложения, цепочку вызовов (gadget chain), которая кончается выполнением команды.

Это CWE-502 «Deserialization of Untrusted Data». В Top 10:2025 оно в A08:2025 Software or Data Integrity Failures, и сценарий там почти наш: состояние пользователя сериализуют и гоняют туда-обратно в каждом запросе, атакующий замечает сигнатуру rO0 (Java-сериализация в base64) и получает выполнение кода на сервере.

Где на маркетплейсе обычно прячется нативная сериализация:

  • Cookie и скрытые поля. Корзина гостя, состояние многошагового оформления заказа, фильтры каталога. Всё, что уходит к клиенту и возвращается, может быть изменено по дороге.
  • Сообщения между сервисами. Заказы публикуют событие, платежи его читают. Если тело события - нативный поток, любой, кто может писать в очередь, пишет и в код потребителя.
  • Кэш. Объекты сессий и карточек товаров, сложенные в общий кэш в родном формате. Доступ к кэшу превращается в выполнение кода во всех сервисах, которые из него читают.
  • Внутренние протоколы и загрузки. Удалённые вызовы поверх нативной сериализации, как T3 у WebLogic из CVE ниже, и загружаемые файлы настроек в YAML.

Это не теория. CVE-2015-4852 (NVD, 18.11.2015): Oracle WebLogic выполнял команды из «crafted serialized Java object» в протоколе T3, через Apache Commons Collections. CVE-2015-7501: то же для целой линейки продуктов Red Hat JBoss. CVE-2017-5941: пакет node-serialize выполнял функцию, пришедшую в данных. CVE-2017-18342: PyYAML до 5.1, где yaml.load() «could execute arbitrary code if used with untrusted data». Log4Shell (CVE-2021-44228) в этот ряд часто ставят по ошибке: там не десериализация, а JNDI-подстановка в строке лога, которая загружала код с LDAP-сервера атакующего (CWE-917).

Как работают сущности XMLспросят на собеседовании

Сущность в XML - это именованная подстановка: парсер встречает &имя; и ставит на это место значение. Пять сущностей есть всегда (&lt;, &gt;, &amp;, &apos;, &quot;), они дают символы, которые иначе сломали бы разметку. Остальные документ объявляет сам, в DTD внутри <!DOCTYPE ...>, и вот здесь начинается разница:

  • Внутренняя сущность несёт значение прямо в объявлении: <!ENTITY shop "Маркетплейс">, и &shop; превращается в строку. Это обычный макрос.
  • Внешняя сущность вместо значения несёт адрес после слова SYSTEM: <!ENTITY xxe SYSTEM "file:///etc/passwd">. Чтобы подставить её, парсер сам открывает этот адрес и читает, что там лежит.
  • Внешний DTD делает то же для всего описания: <!DOCTYPE feed SYSTEM "http://..."> просит парсер скачать DTD ещё до первого элемента. Ни одной сущности в теле документа при этом может не быть.

Отсюда два превращения парсера. С адресом file: он становится читалкой файлов с правами сервиса: прочитанное попадает в поле документа, оттуда в базу, в карточку товара или в текст ошибки, который увидит отправитель. С адресом http: он становится HTTP-клиентом, который ходит изнутри сети: к метаданным облака, к админке без пароля, к соседнему сервису. Даже если ответ к атакующему не вернётся, запрос уже ушёл с адреса, которому внутренняя сеть доверяет. Это тот же SSRF, только адрес пришёл не в параметре, а внутри документа. Документ ничего не взламывает: парсер честно выполняет стандарт XML, а настройки по умолчанию у части парсеров это разрешают, как у DocumentBuilderFactory в примере ниже.

На маркетплейсе XML приходит снаружи в нескольких местах, и каждое выглядит безобидно:

  • Фид каталога от продавца. Самая очевидная точка, её обычно и проверяют.
  • Импорт накладных и счетов от поставщиков. Часто это отдельный сервис или интеграция, написанная другой командой и в другое время, со своим парсером. Документ присылает контрагент, а не сотрудник, значит это недоверенный ввод, сколько бы договоров ни было подписано.
  • SAML-ответ корпоративного входа. Если кабинет продавца пускает сотрудников через их собственный провайдер входа, ответ провайдера - это XML. Он подписан, но приходит POST-запросом через браузер пользователя, а разбирают его до проверки подписи. Шпаргалка OWASP по SAML отдельно требует проверять ответ по локальным доверенным копиям схем: «Never allow automatic download of schemas from third party locations».

Billion laughs: отказ в обслуживании без внешних ресурсовспросят на собеседовании

Внешние сущности можно запретить, а внутренние останутся, и их хватает, чтобы положить сервис. CWE-776 «Improper Restriction of Recursive Entity References in DTDs ('XML Entity Expansion')» описывает это так: если DTD содержит много вложенных или рекурсивных сущностей, разбор ведёт к «explosive growth of data when parsed, causing a denial of service». Каноническое имя атаки - billion laughs.

Механика видна в Python-примере ниже. Сущность a равна строке ha, b - это десять раз &a;, c - десять раз &b;, и так далее. Каждый уровень умножает длину на десять, поэтому документ в несколько сотен байт после раскрытия весит гигабайты. Документация Python говорит то же: «The exponential expansion results in several gigabytes of text and consumes lots of memory and CPU time». Там же описан quadratic blowup: одна большая сущность в пару тысяч символов, повторённая много раз. Он слабее, зато обходит защиту парсеров, которые запрещают глубокую вложенность.

Что из этого следует для кода:

  • Лимит размера тела сам по себе не спасает: опасен не размер входа, а размер результата раскрытия. Фид в 1 КБ проходит любой лимит.
  • Запрет DTD закрывает и XXE, и billion laughs одним правилом: нет DOCTYPE, негде объявить сущности. CWE-776 предлагает то же: «prohibit the use of DTDs or use an XML parser that limits the expansion of recursive DTD entities».
  • Если DTD всё-таки нужен, шпаргалка OWASP требует трёх вещей сразу: «disable external entity resolution and external DTD loading, and limit entity expansion».
  • У Python важна версия Expat: ниже 2.7.2 она может быть уязвима к обоим вариантам, и смотреть надо на pyexpat.EXPAT_VERSION там, где сервис реально работает, а не на ноутбуке разработчика.

Как выглядит в коде: XXE

Импорт фида поставщика: разбираем XML и берём название товара. Ниже то, что делает парсер по умолчанию, если ему дать документ с DOCTYPE. Java-пример сам создаёт файл с «паролем» и ссылается на него из сущности, чтобы утечку было видно без доступа к чужим файлам.

live example

import java.io.ByteArrayInputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import javax.xml.parsers.DocumentBuilderFactory;

public class SupplierFeedImport {
    public static void main(String[] args) throws Exception {
        Path secret = Files.createTempFile("db", ".properties");
        Files.writeString(secret, "db.password=s3cr3t-prod");

        String feed = """
            <?xml version="1.0"?>
            <!DOCTYPE feed [ <!ENTITY xxe SYSTEM "%s"> ]>
            <feed><item><sku>A-1</sku><title>&xxe;</title></item></feed>
            """.formatted(secret.toUri()).strip();

        DocumentBuilderFactory dbf = DocumentBuilderFactory.newDefaultInstance();
        var doc = dbf.newDocumentBuilder().parse(new ByteArrayInputStream(feed.getBytes()));
        String title = doc.getElementsByTagName("title").item(0).getTextContent();
        System.out.println("название товара в каталоге: " + title);
    }
}
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

live example

package main

import (
	"encoding/xml"
	"fmt"
	"strings"
)

type Feed struct {
	Items []struct {
		SKU   string `xml:"sku"`
		Title string `xml:"title"`
	} `xml:"item"`
}

const payload = `<!DOCTYPE feed [ <!ENTITY xxe SYSTEM "file:///etc/passwd"> ]>
<feed><item><sku>A-1</sku><title>&xxe;</title></item></feed>`

func main() {
	tok, _ := xml.NewDecoder(strings.NewReader(payload)).Token()
	fmt.Printf("первый токен: %T %q\n", tok, tok)

	var feed Feed
	fmt.Println("Strict=true:", xml.Unmarshal([]byte(payload), &feed))

	lax := xml.NewDecoder(strings.NewReader(payload))
	lax.Strict = false
	feed = Feed{}
	err := lax.Decode(&feed)
	fmt.Printf("Strict=false: err=%v, title=%q\n", err, feed.Items[0].Title)
}
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

live example

const looksSafe = (xml) => !xml.includes('<!ENTITY');

const payloads = {
  'сущность в документе': `<!DOCTYPE feed [ <!ENTITY xxe SYSTEM "file:///etc/passwd"> ]>
<feed><title>&xxe;</title></feed>`,
  'внешний DTD': `<!DOCTYPE feed SYSTEM "http://169.254.169.254/latest/meta-data/">
<feed><title>&xxe;</title></feed>`,
};

for (const [name, xml] of Object.entries(payloads)) {
  if (!looksSafe(xml)) {
    console.log(`${name}: отклонено`);
    continue;
  }
  const dtd = xml.match(/<!DOCTYPE\s+\w+\s+SYSTEM\s+"([^"]+)"/)[1];
  console.log(`${name}: пропущено, парсер с загрузкой DTD пойдёт за ${dtd}`);
}
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

live example

import xml.etree.ElementTree as ET
import pyexpat

print("Expat:", pyexpat.EXPAT_VERSION)

external = """<!DOCTYPE feed [ <!ENTITY xxe SYSTEM "file:///etc/passwd"> ]>
<feed><title>&xxe;</title></feed>"""
try:
    ET.fromstring(external)
except ET.ParseError as e:
    print("внешняя сущность:", e)

laughs = """<!DOCTYPE feed [
  <!ENTITY a "ha">
  <!ENTITY b "&a;&a;&a;&a;&a;&a;&a;&a;&a;&a;">
  <!ENTITY c "&b;&b;&b;&b;&b;&b;&b;&b;&b;&b;">
  <!ENTITY d "&c;&c;&c;&c;&c;&c;&c;&c;&c;&c;">
  <!ENTITY e "&d;&d;&d;&d;&d;&d;&d;&d;&d;&d;">
]>
<feed><title>&e;</title></feed>"""
title = ET.fromstring(laughs).find("title").text
print(f"внутренние сущности: из {len(laughs)} байт получилось {len(title)} символов")
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

Что здесь видно:

  • Java. DocumentBuilderFactory с настройками по умолчанию разрешил внешнюю сущность и подставил содержимое файла в название товара: db.password=s3cr3t-prod. С адресом http://... вместо file: парсер так же сделал бы запрос.
  • Go. encoding/xml отдаёт <!DOCTYPE ...> как токен xml.Directive и не разбирает его. Сущности Decoder берёт только из поля Entity и пяти стандартных (lt, gt, amp, apos, quot); в строгом режиме неизвестная сущность даёт ошибку, в нестрогом остаётся текстом &xxe;. О загрузке внешних сущностей или DTD документация пакета не говорит ничего, и настройки для этого у Decoder нет.
  • Node. XML-парсера в стандартной библиотеке нет, все парсеры сторонние, и поведение с DTD у них разное. Пример показывает типичную самодельную проверку: она ищет только <!ENTITY и пропускает документ с внешним DTD, где сущностей в тексте нет вовсе.
  • Python. Документация: встроенные парсеры работают на Expat, а «By default, Expat itself does not access local files or create network connections». Поэтому внешняя сущность дала ошибку undefined entity. Но внутренние сущности раскрываются: 258 байт превратились в 20 000 символов, а ещё пара уровней даст мегабайты. Версии Expat ниже 2.7.2, по той же странице, могут быть уязвимы к «billion laughs»; проверяйте pyexpat.EXPAT_VERSION там, где сервис реально работает.

Общий вывод из четырёх примеров: ни один язык не даёт одного правила «из коробки». У Java опасно поведение по умолчанию, у Python закрыта только часть угроз, у Go защита не обещана документацией, у Node всё зависит от сторонней библиотеки. Поэтому правило «документ с DOCTYPE отклоняется» стоит записать явно, в коде каждого входа.

Как чинить XXEспросят на собеседовании

Главное правило шпаргалки OWASP: «Disable document type definitions (DTDs) whenever possible. Reject DOCTYPE declarations if the parser supports it.» Фиду каталога, накладной и SAML-ответу DTD не нужен, значит, документ с DOCTYPE отклоняют целиком. И ещё одно правило оттуда же: если парсер не поддерживает нужную настройку безопасности, это ошибка конфигурации, а не повод разбирать документ с половиной защиты.

Порядок решений для любого входа с XML:

  1. Нужен ли DTD этому входу? Почти всегда нет. Тогда документ с DOCTYPE отклоняют, и вопрос о внешних сущностях и billion laughs снимается сразу.
  2. Умеет ли парсер отклонять DOCTYPE? Если да, включают эту настройку. Если нет, отключают внешние сущности, загрузку внешнего DTD и ограничивают раскрытие сущностей, все три пункта вместе.
  3. Не сработала настройка - не разбираем. Шпаргалка требует: «Stop processing if any required security setting fails; do not catch the exception and continue parsing». Исключение при создании фабрики должно ронять сервис при старте, а не тихо давать парсер по умолчанию.
  4. Одна фабрика на весь код. Все XML-процессоры, включая XSLT, проверку по схеме и XPath, создаются через одно место с проверенной настройкой. Новый сервис импорта берёт её, а не пишет свою.
  5. Лимиты отдельно. Размер тела и время разбора ограничивают независимо от DTD, потому что XXE-защита закрывает не все виды отказа в обслуживании.
  6. Версия парсера. Защита в старых версиях бывает неполной, как у Expat ниже 2.7.2, поэтому библиотеки парсеров живут в общем скане зависимостей.

live example

import java.io.ByteArrayInputStream;
import javax.xml.XMLConstants;
import javax.xml.parsers.DocumentBuilderFactory;
import org.xml.sax.SAXParseException;
import org.xml.sax.helpers.DefaultHandler;

public class HardenedFeedImport {
    static DocumentBuilderFactory hardenedFactory() throws Exception {
        DocumentBuilderFactory dbf = DocumentBuilderFactory.newDefaultInstance();
        dbf.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
        dbf.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
        return dbf;
    }

    static void importFeed(String feed) throws Exception {
        try {
            var builder = hardenedFactory().newDocumentBuilder();
            builder.setErrorHandler(new DefaultHandler());
            var doc = builder.parse(new ByteArrayInputStream(feed.getBytes()));
            System.out.println("принято: " + doc.getElementsByTagName("title").item(0).getTextContent());
        } catch (SAXParseException e) {
            System.out.println("отклонено: " + e.getMessage());
        }
    }

    public static void main(String[] args) throws Exception {
        importFeed("<feed><item><sku>A-1</sku><title>Чайник</title></item></feed>");
        importFeed("""
            <!DOCTYPE feed [ <!ENTITY xxe SYSTEM "file:///etc/passwd"> ]>
            <feed><item><sku>A-1</sku><title>&xxe;</title></item></feed>""");
    }
}
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

live example

package main

import (
	"bytes"
	"encoding/xml"
	"errors"
	"fmt"
	"io"
	"strings"
)

type Feed struct {
	Items []struct {
		SKU   string `xml:"sku"`
		Title string `xml:"title"`
	} `xml:"item"`
}

var errDoctype = errors.New("DOCTYPE запрещён в фиде поставщика")

func rejectDoctype(data []byte) error {
	dec := xml.NewDecoder(bytes.NewReader(data))
	for {
		tok, err := dec.RawToken()
		if err == io.EOF {
			return nil
		}
		if err != nil {
			return err
		}
		if d, ok := tok.(xml.Directive); ok && strings.HasPrefix(string(d), "DOCTYPE") {
			return errDoctype
		}
	}
}

func importFeed(data []byte) {
	if err := rejectDoctype(data); err != nil {
		fmt.Println("отклонено:", err)
		return
	}
	var feed Feed
	if err := xml.Unmarshal(data, &feed); err != nil {
		fmt.Println("отклонено:", err)
		return
	}
	fmt.Println("принято:", feed.Items[0].Title)
}

func main() {
	importFeed([]byte(`<feed><item><sku>A-1</sku><title>Чайник</title></item></feed>`))
	importFeed([]byte(`<!DOCTYPE feed [ <!ENTITY xxe SYSTEM "http://169.254.169.254/latest/meta-data/"> ]>
<feed><item><sku>A-1</sku><title>&xxe;</title></item></feed>`))
}
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

live example

const assert = require('assert');

function rejectDtd(xml) {
  if (/<!\s*(DOCTYPE|ENTITY)/i.test(xml)) {
    throw new Error('DOCTYPE и ENTITY в фиде поставщика запрещены');
  }
  return xml;
}

const payloads = [
  '<feed><title>Чайник</title></feed>',
  '<!DOCTYPE feed [ <!ENTITY xxe SYSTEM "file:///etc/passwd"> ]><feed><title>&xxe;</title></feed>',
  '<!DOCTYPE feed SYSTEM "http://169.254.169.254/latest/meta-data/"><feed/>',
];

for (const xml of payloads) {
  try {
    rejectDtd(xml);
    console.log('принято:', xml);
  } catch (e) {
    console.log('отклонено:', e.message);
  }
}
assert.throws(() => rejectDtd('<!doctype feed SYSTEM "x"><feed/>'));
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

live example

import xml.etree.ElementTree as ET
from xml.parsers import expat


class ForbiddenDtd(ValueError):
    pass


def forbid(*_):
    raise ForbiddenDtd("DOCTYPE и ENTITY в фиде поставщика запрещены")


def parse_feed(data: bytes) -> ET.Element:
    probe = expat.ParserCreate()
    probe.StartDoctypeDeclHandler = forbid
    probe.EntityDeclHandler = forbid
    probe.Parse(data, True)
    return ET.fromstring(data)


payloads = [
    "<feed><title>Чайник</title></feed>".encode(),
    b'<!DOCTYPE feed [ <!ENTITY a "ha"> ]><feed><title>&a;</title></feed>',
    b'<!DOCTYPE feed SYSTEM "http://169.254.169.254/latest/meta-data/"><feed/>',
]
for data in payloads:
    try:
        print("принято:", parse_feed(data).find("title").text)
    except ForbiddenDtd as e:
        print("отклонено:", e)
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

Java-вариант взят прямо из шпаргалки: disallow-doctype-decl отклоняет документ на первом же DOCTYPE, FEATURE_SECURE_PROCESSING включает ограничения обработки. В Go отказ сделан явным: парсер и так не тянет внешние ресурсы, но правило «фид без DTD» одно для всех сервисов. В Python DOCTYPE ловит сам Expat через обработчики объявлений, без регулярок по тексту. В Node, где своего парсера нет, проверка идёт до сторонней библиотеки и отклоняет любой DOCTYPE, а не только ENTITY. Она дополняет настройку парсера, а не заменяет её: смотрите, что выбранная библиотека делает с DTD, а у обёрток над libxml2 OWASP требует не включать XML_PARSE_NOENT и XML_PARSE_DTDLOAD.

В обработчике это выглядит так (блок без запуска: нужен фреймворк или сторонняя библиотека):

@PostMapping(path = "/seller/feeds", consumes = MediaType.APPLICATION_XML_VALUE)
public ResponseEntity<Void> uploadFeed(InputStream body) throws Exception {
    XMLInputFactory xif = XMLInputFactory.newDefaultFactory();
    xif.setProperty(XMLInputFactory.SUPPORT_DTD, false);
    xif.setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, false);
    xif.setXMLResolver((publicId, systemId, baseURI, namespace) -> {
        throw new XMLStreamException("External references are not allowed");
    });
    XMLStreamReader reader = xif.createXMLStreamReader(body);
    feedImporter.importFrom(reader);
    return ResponseEntity.accepted().build();
}
func (h *FeedHandler) Upload(w http.ResponseWriter, r *http.Request) {
	data, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 5<<20))
	if err != nil {
		http.Error(w, "фид больше 5 МБ", http.StatusRequestEntityTooLarge)
		return
	}
	if err := rejectDoctype(data); err != nil {
		http.Error(w, "DOCTYPE в фиде запрещён", http.StatusBadRequest)
		return
	}
	var feed Feed
	if err := xml.Unmarshal(data, &feed); err != nil {
		http.Error(w, "фид не разобран", http.StatusBadRequest)
		return
	}
	h.importer.Import(r.Context(), feed)
	w.WriteHeader(http.StatusAccepted)
}
app.post('/seller/feeds', express.text({ type: 'application/xml', limit: '5mb' }), (req, res) => {
  try {
    rejectDtd(req.body);
  } catch {
    return res.status(400).json({ error: 'DOCTYPE в фиде запрещён' });
  }
  feedImporter.import(req.body);
  res.status(202).end();
});
from defusedxml import DefusedXmlException
from defusedxml.ElementTree import fromstring
from fastapi import HTTPException, Request


@app.post("/seller/feeds", status_code=202)
async def upload_feed(request: Request):
    body = await request.body()
    try:
        feed = fromstring(body, forbid_dtd=True)
    except DefusedXmlException:
        raise HTTPException(status_code=400, detail="DOCTYPE в фиде запрещён")
    feed_importer.import_feed(feed)

StAX-настройка тоже из шпаргалки: там отмечено, что DTD у XMLInputFactory по умолчанию включён, а значение по умолчанию для внешних сущностей не определено, поэтому выставляют оба свойства и резолвер, который бросает исключение. Для Python OWASP советует defusedxml вместо функций стандартной библиотеки и forbid_dtd=True, когда DTD не нужен; по документации пакета forbid_entities и forbid_external и так включены по умолчанию, а запреты поднимают DTDForbidden, EntitiesForbidden и ExternalReferenceForbidden.

Что такое нативная десериализация и откуда берутся гаджетыспросят на собеседовании

Сериализация превращает объект в байты, десериализация собирает его обратно. Шпаргалка OWASP делит форматы на два вида. Форматы данных, JSON и XML, описывают только значения: строки, числа, списки. Нативные форматы языка, сериализация Serializable у Java и pickle у Python, восстанавливают объект конкретного класса со всеми ссылками и дают классу вмешаться в процесс. По словам OWASP, они «usually offer more features than JSON or XML, including customization of the serialization process», и именно эти возможности на недоверенных данных превращаются в отказ в обслуживании, обход доступа или выполнение кода.

Что происходит при чтении нативного потока, по шагам:

  1. Класс называет поток. Не приложение решает, объект какого класса создать, а байты: Java-поток хранит полное имя класса, pickle - модуль и имя функции.
  2. Десериализатор ищет этот класс среди всех доступных приложению, а не среди ожидаемых. В шпаргалке это сказано так: «A deserializer can only instantiate types that it knows about». «Знает» он всё, что лежит в зависимостях.
  3. При восстановлении работает код самого класса: readObject и readResolve у Java, у pickle вызываемый объект, который вернул __reduce__. Данные для этого кода берутся из потока, то есть их задал отправитель.
  4. Только потом приложение получает объект и приводит его к своему типу. К этому моменту всё уже выполнилось.

Гаджет - это класс из зависимостей, который при восстановлении делает что-то полезное для атакующего. Сам по себе он не уязвим: он делает свою обычную работу, например вызывает метод у объекта из своего поля. Но поля задаёт поток, и атакующий кладёт туда следующий гаджет, в него следующий, пока цепочка не дойдёт до вызова, который запускает команду. Поэтому довод «класс не наш» ничего не меняет: важно не кто его написал, а то, что он есть в зависимостях и десериализатор согласен его создать. В CVE-2015-4852 цепочка шла через Apache Commons Collections, библиотеку из модулей самого WebLogic (com.bea.core.apache.commons.collections.jar, по описанию NVD), а не через код приложения.

Из этого следует вывод, который часто не делают. Обновление одной библиотеки закрывает одну цепочку, но не саму возможность: пока сервис читает нативный поток от недоверенного отправителя, вопрос только в том, найдётся ли подходящий набор классов. В вики Jackson о гаджетах сказано прямо: «new ones are found over time».

Почему JSON не безопасен автоматическиспросят на собеседовании

JSON сам по себе описывает только данные. Документация pickle так и сравнивает: «Unlike pickle, deserializing untrusted JSON does not in itself create an arbitrary code execution vulnerability». Ключевые слова здесь «in itself». Опасность возвращается, как только библиотека разрешает данным выбрать класс, то есть включён полиморфный режим. Это шаги 1-2 из списка выше, только в текстовом формате.

  • Jackson. Глобальный default typing кладёт в JSON имя Java-класса, и при чтении создаётся тот класс, который назван. Как настроить безопасно, разобрано в разделе «Как чинить десериализацию».
  • Другие библиотеки. Шпаргалка OWASP перечисляет такие же режимы: у json-io свойство @type «allows deserialization of any class», fastjson2 безопасен, только пока не включён autotype, у Json.NET для .NET безопасен только TypeNameHandling.None.
  • Общий признак OWASP формулирует для ревью: искать сериализаторы, «where the type is set by a user controlled variable». Это относится и к самодельным реестрам вроде Go-примера ниже, где имя типа приходит в поле type.

Стандартные парсеры JSON сами типы из данных не выбирают. encoding/json в Go при разборе в any, по документации, кладёт туда только bool, float64, string, []any, map[string]any или nil. json у Python по умолчанию даёт dict, list, str, int, float, True, False и None. JSON.parse у Node функций не создаёт, пока reviver или библиотека поверх не начнут превращать строки в код. Но JSON без схемы всё равно пропускает лишние поля и неверные типы, поэтому исправленные примеры ниже проверяют набор полей, а не только синтаксис.

Как выглядит в коде: десериализация

Корзину гостя хранят в cookie, чтобы не заводить сессию. Удобно положить туда объект целиком и прочитать обратно. Пример безвреден: вместо команды гаджет печатает строку, но вызван он ровно там, где настоящая цепочка вызвала бы Runtime.exec или os.system.

live example

import java.io.*;
import java.util.Base64;

public class CartCookie {
    record CartSnapshot(long customerId, String items) implements Serializable {}

    static class ReportJob implements Serializable {
        String command = "echo pwned";

        private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
            in.defaultReadObject();
            System.out.println("readObject выполнился до проверки типа, команда: " + command);
        }
    }

    static String attackerCookie() throws IOException {
        var bytes = new ByteArrayOutputStream();
        try (var out = new ObjectOutputStream(bytes)) {
            out.writeObject(new ReportJob());
        }
        return Base64.getEncoder().encodeToString(bytes.toByteArray());
    }

    public static void main(String[] args) throws Exception {
        String cookie = attackerCookie();
        System.out.println("cookie начинается с " + cookie.substring(0, 4));
        try (var in = new ObjectInputStream(new ByteArrayInputStream(Base64.getDecoder().decode(cookie)))) {
            CartSnapshot cart = (CartSnapshot) in.readObject();
            System.out.println("корзина: " + cart);
        } catch (ClassCastException e) {
            System.out.println("приведение типа упало, но поздно: " + e.getClass().getSimpleName());
        }
    }
}
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

live example

package main

import (
	"encoding/json"
	"fmt"
)

type Command interface{ Run() string }

type AddToCart struct {
	SKU string `json:"sku"`
	Qty int    `json:"qty"`
}

type IssueRefund struct {
	OrderID string `json:"orderId"`
	Card    string `json:"card"`
}

func (c AddToCart) Run() string   { return fmt.Sprintf("в корзину %s x%d", c.SKU, c.Qty) }
func (c IssueRefund) Run() string { return fmt.Sprintf("возврат по заказу %s на карту %s", c.OrderID, c.Card) }

var registry = map[string]func() Command{
	"AddToCart":   func() Command { return &AddToCart{} },
	"IssueRefund": func() Command { return &IssueRefund{} },
}

type envelope struct {
	Type    string          `json:"type"`
	Payload json.RawMessage `json:"payload"`
}

func handleStorefront(body []byte) {
	var env envelope
	if err := json.Unmarshal(body, &env); err != nil {
		fmt.Println("ошибка:", err)
		return
	}
	cmd := registry[env.Type]()
	json.Unmarshal(env.Payload, cmd)
	fmt.Println("выполнено:", cmd.Run())
}

func main() {
	handleStorefront([]byte(`{"type":"AddToCart","payload":{"sku":"SKU-1","qty":2}}`))
	handleStorefront([]byte(`{"type":"IssueRefund","payload":{"orderId":"A-1007","card":"4000 0000 0000 0002"}}`))
}
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

live example

const FUNC_PREFIX = '_$$ND_FUNC$$_';

function unserialize(text) {
  return JSON.parse(text, (key, value) => {
    if (typeof value === 'string' && value.startsWith(FUNC_PREFIX)) {
      return eval('(' + value.slice(FUNC_PREFIX.length) + ')');
    }
    return value;
  });
}

const cookie = JSON.stringify({
  customerId: 42,
  items: ['SKU-1'],
  rce: FUNC_PREFIX + "function () { console.log('pwned: код выполнился при разборе cookie'); return 1; }()",
});

const cart = unserialize(cookie);
console.log('корзина:', cart);
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

live example

import base64
import pickle


class Exploit:
    def __reduce__(self):
        return (print, ("pwned: вызван print при разборе, мог быть os.system",))


cookie = base64.b64encode(pickle.dumps(Exploit())).decode()
print("cookie корзины:", cookie[:12], "...")

cart = pickle.loads(base64.b64decode(cookie))
print("pickle.loads вернул:", cart)
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

  • Java. Код класса из потока выполнился внутри readObject(), а ClassCastException случился потом, когда всё уже произошло. Приведение к ожидаемому типу не защищает. В настоящей атаке ReportJob не нужен: цепочку собирают из классов популярных библиотек в classpath, как в CVE-2015-4852 с Commons Collections. Javadoc ObjectInputFilter начинается словами «Deserialization of untrusted data is inherently dangerous and should be avoided».
  • Go. Гаджетов нет, но та же ошибка проектирования: тип объекта выбирает строка из запроса. Витрина умеет только класть в корзину, а реестр общий, и запрос с "type":"IssueRefund" запускает возврат денег.
  • Node. Это схема node-serialize: в исходнике пакета строка с префиксом _$$ND_FUNC$$_ уходит в eval. Скобки () в конце делают функцию немедленно вызываемой (IIFE), и код срабатывает прямо при разборе, это и есть CVE-2017-5941.
  • Python. Документация pickle: «The pickle module is not secure. Only unpickle data you trust.» __reduce__ возвращает вызываемый объект и аргументы, и pickle.loads его вызывает. Здесь это print, в атаке os.system.

Общее у четырёх примеров одно: решение о том, что создать и что вызвать, принимают данные, а проверка, если она есть, стоит после разбора и уже ничего не отменяет. Исправленные версии ниже переносят решение в код: тип известен заранее, а всё, что не совпало, отклоняется до создания объекта.

Как чинить десериализациюспросят на собеседовании

Шпаргалка OWASP по десериализации даёт порядок. Первое: «A great reduction of risk is achieved by avoiding native (de)serialization formats»: снаружи принимают JSON в DTO, где каждое поле известно заранее. Второе: «Only Deserialize Signed Data», если формат всё же свой. Третье, для ObjectInputStream: «Configure serialization filters with an application-specific class allowlist and resource limits before reading objects».

На практике это лестница, по которой спускаются, только если верхняя ступень невозможна:

  1. Снаружи только формат данных. JSON или XML без DTD в DTO, где каждое поле известно заранее, плюс схема и отказ на неизвестных полях. Для cookie корзины, тела запроса и события в очереди это почти всегда возможно.
  2. Данные уходят к клиенту и возвращаются - подпись. HMAC над байтами, проверка за постоянное время и только потом разбор. Ключ хранится на сервере, и клиент не может собрать поток, который пройдёт проверку.
  3. Нативный поток неизбежен - фильтр. Белый список классов конкретного входа и лимиты на глубину, ссылки, массивы и байты, как в примере ниже. Для своих классов, которые не должны читаться из потока вообще, шпаргалка предлагает final readObject, который всегда бросает исключение, а для полей, которые нельзя ни отдавать, ни принимать, transient.
  4. Изоляция. Шпаргалка советует держать код, где могут оказаться гаджеты, отдельно от кода, который принимает данные из интернета. Чем меньше библиотек у сервиса, читающего поток, тем меньше материала для цепочки.

live example

import java.io.*;

public class SafeCartCookie {
    record CartSnapshot(long customerId, String items) implements Serializable {}

    static class ReportJob implements Serializable {
        private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
            in.defaultReadObject();
            System.out.println("сюда не должны попасть");
        }
    }

    static final ObjectInputFilter CART_ONLY = ObjectInputFilter.Config.createFilter(
        "maxdepth=3;maxrefs=50;maxbytes=4096;SafeCartCookie$CartSnapshot;!*");

    static byte[] serialize(Object value) throws IOException {
        var bytes = new ByteArrayOutputStream();
        try (var out = new ObjectOutputStream(bytes)) {
            out.writeObject(value);
        }
        return bytes.toByteArray();
    }

    static void readCart(byte[] data) throws Exception {
        try (var in = new ObjectInputStream(new ByteArrayInputStream(data))) {
            in.setObjectInputFilter(CART_ONLY);
            System.out.println("принято: " + in.readObject());
        } catch (InvalidClassException e) {
            System.out.println("отклонено: " + e.getMessage());
        }
    }

    public static void main(String[] args) throws Exception {
        readCart(serialize(new CartSnapshot(42, "SKU-1x2")));
        readCart(serialize(new ReportJob()));
    }
}
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

live example

package main

import (
	"bytes"
	"encoding/json"
	"errors"
	"fmt"
)

type AddToCart struct {
	SKU string `json:"sku"`
	Qty int    `json:"qty"`
}

type storefrontRequest struct {
	Type    string    `json:"type"`
	Payload AddToCart `json:"payload"`
}

func decodeStorefront(body []byte) (AddToCart, error) {
	dec := json.NewDecoder(bytes.NewReader(body))
	dec.DisallowUnknownFields()
	var req storefrontRequest
	if err := dec.Decode(&req); err != nil {
		return AddToCart{}, err
	}
	if req.Type != "AddToCart" {
		return AddToCart{}, errors.New("тип " + req.Type + " не принимается с витрины")
	}
	if req.Payload.Qty < 1 || req.Payload.Qty > 99 {
		return AddToCart{}, errors.New("qty вне диапазона 1..99")
	}
	return req.Payload, nil
}

func main() {
	bodies := []string{
		`{"type":"AddToCart","payload":{"sku":"SKU-1","qty":2}}`,
		`{"type":"IssueRefund","payload":{"sku":"SKU-1","qty":1}}`,
		`{"type":"AddToCart","payload":{"orderId":"A-1007","card":"4000 0000 0000 0002"}}`,
	}
	for _, body := range bodies {
		cmd, err := decodeStorefront([]byte(body))
		if err != nil {
			fmt.Println("отклонено:", err)
			continue
		}
		fmt.Printf("принято: %+v\n", cmd)
	}
}
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

live example

const CART_FIELDS = { customerId: 'number', items: 'object' };

function parseCart(text) {
  const cart = JSON.parse(text, (key, value) => {
    if (key === '__proto__') throw new Error('поле __proto__ запрещено');
    return value;
  });
  for (const key of Object.keys(cart)) {
    if (!(key in CART_FIELDS)) throw new Error(`неизвестное поле ${key}`);
  }
  for (const [key, type] of Object.entries(CART_FIELDS)) {
    if (typeof cart[key] !== type) throw new Error(`поле ${key} должно быть ${type}`);
  }
  if (!Array.isArray(cart.items) || !cart.items.every((s) => /^SKU-\d+$/.test(s))) {
    throw new Error('items: ожидается массив артикулов');
  }
  return cart;
}

const cookies = [
  '{"customerId":42,"items":["SKU-1"]}',
  '{"customerId":42,"items":["SKU-1"],"rce":"_$$ND_FUNC$$_function(){console.log(\'pwned\')}()"}',
  '{"customerId":"42","items":["SKU-1"]}',
  '{"customerId":42,"items":[],"__proto__":{"isAdmin":true}}',
];
for (const cookie of cookies) {
  try {
    console.log('принято:', parseCart(cookie));
  } catch (e) {
    console.log('отклонено:', e.message);
  }
}
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

live example

import io
import json
import pickle


class NoGlobalsUnpickler(pickle.Unpickler):
    def find_class(self, module, name):
        raise pickle.UnpicklingError(f"global '{module}.{name}' is forbidden")


class Exploit:
    def __reduce__(self):
        return (print, ("pwned",))


for data in (pickle.dumps({"customer_id": 42, "items": ["SKU-1"]}), pickle.dumps(Exploit())):
    try:
        print("принято:", NoGlobalsUnpickler(io.BytesIO(data)).load())
    except pickle.UnpicklingError as e:
        print("отклонено:", e)

cart = json.loads('{"customer_id": 42, "items": ["SKU-1"]}')
print("а лучше JSON, он не вызывает функций:", cart)
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

  • Java. Фильтр из JEP 290 (JDK 9) вызывается «before the object is instantiated and deserialized»: класс не из списка отклонён, его readObject не запускался. Синтаксис шаблона тот же, что у системного свойства jdk.serialFilter: лимиты maxdepth, maxrefs, maxbytes, разрешённые классы, !* в конце запрещает всё остальное. JEP оговаривает, что фильтр не вызывается для примитивов и строк, поэтому размер входа ограничивают отдельно, а шпаргалка добавляет: «Filtering does not make arbitrary untrusted deserialization safe». Для cookie правильный ответ всё равно JSON.
  • Go. Тип команды не выбирается из запроса: у витрины свой DTO и свой список разрешённых типов, а DisallowUnknownFields (по документации «return an error when the destination is a struct and the input contains object keys which do not match any non-ignored, exported fields») отклоняет поля, которых в DTO нет.
  • Node. JSON.parse сам функций не создаёт; reviver отсекает __proto__, схема проверяет набор полей и типы. В реальном сервисе схему описывают библиотекой валидации, но принцип тот же: неизвестное поле означает отказ.
  • Python. find_class, по документации, вызывается «whenever a global (i.e., a class or a function) is requested», и запрет на нём закрывает __reduce__ с print или os.system. Документация сама предупреждает, что белый список надо собирать очень осторожно, поэтому для внешних данных выбирают JSON.

Полиморфный JSON, YAML и подпись (блок без запуска):

@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
@JsonSubTypes({
    @JsonSubTypes.Type(value = OrderPaid.class, name = "OrderPaid"),
    @JsonSubTypes.Type(value = OrderShipped.class, name = "OrderShipped")
})
public sealed interface OrderEvent permits OrderPaid, OrderShipped {}

ObjectMapper eventsMapper = new ObjectMapper();
eventsMapper.activateDefaultTyping(
    BasicPolymorphicTypeValidator.builder()
        .allowIfSubType("ru.market.orders.events.")
        .build(),
    ObjectMapper.DefaultTyping.NON_FINAL);
func signCart(key []byte, cart []byte) string {
	mac := hmac.New(sha256.New, key)
	mac.Write(cart)
	return base64.RawURLEncoding.EncodeToString(cart) + "." +
		base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}

func verifyCart(key []byte, cookie string) ([]byte, error) {
	body, sig, ok := strings.Cut(cookie, ".")
	if !ok {
		return nil, errors.New("нет подписи")
	}
	cart, err := base64.RawURLEncoding.DecodeString(body)
	if err != nil {
		return nil, err
	}
	got, err := base64.RawURLEncoding.DecodeString(sig)
	if err != nil {
		return nil, err
	}
	mac := hmac.New(sha256.New, key)
	mac.Write(cart)
	if !hmac.Equal(got, mac.Sum(nil)) {
		return nil, errors.New("подпись не сошлась")
	}
	return cart, nil
}
const Ajv = require('ajv');

const ajv = new Ajv();
const validateCart = ajv.compile({
  type: 'object',
  additionalProperties: false,
  required: ['customerId', 'items'],
  properties: {
    customerId: { type: 'integer', minimum: 1 },
    items: { type: 'array', maxItems: 100, items: { type: 'string', pattern: '^SKU-\\d+$' } },
  },
});

app.post('/cart', express.json({ limit: '16kb' }), (req, res) => {
  if (!validateCart(req.body)) return res.status(400).json({ errors: validateCart.errors });
  cartService.save(req.body);
  res.status(204).end();
});
import base64
import hashlib
import hmac
import json

import yaml

config = yaml.safe_load(uploaded_bytes)


def load_cart(cookie: str, key: bytes) -> dict:
    body, _, sig = cookie.rpartition(".")
    expected = hmac.new(key, body.encode(), hashlib.sha256).hexdigest()
    if not hmac.compare_digest(sig, expected):
        raise ValueError("подпись не сошлась")
    return json.loads(base64.urlsafe_b64decode(body))

Про Jackson. Глобальный полиморфизм кладёт в JSON имя Java-класса, и тогда класс выбирает отправитель. В вики Jackson это прямо названо дырой, если источник недоверенный: подходящие классы называются «deserialization gadget», чёрный список внутри библиотеки их ловит, но «new ones are found over time».

enableDefaultTyping() с версии 2.10 помечен Deprecated в пользу activateDefaultTyping(PolymorphicTypeValidator), то есть со списком разрешённых классов. Лучше же вообще не пускать имя класса в данные: JsonTypeInfo.Id.NAME берёт логическое имя, которое сопоставляется только с перечисленными подтипами. Шпаргалка OWASP так и пишет про jackson-databind: безопасен, «as long as polymorphism is not used».

Та же шпаргалка разбирает соседние библиотеки, и у каждой своя оговорка:

  • SnakeYAML безопасен только с SafeConstructor.
  • XStream с 1.4.17 безопасен, пока не ослаблены белый список и другие настройки; более ранние версии создают любой класс.
  • Kryo с 5.0.0 безопасен, пока не выключена регистрация классов.
  • java.beans.XMLDecoder не использовать с недоверенными данными вообще.

Про YAML в Python. По вики PyYAML вызов yaml.load без Loader устарел с 5.1, UnsafeLoader (он же Loader) исполняет теги вроде !!python/object/new:os.system, для недоверенного ввода предназначен SafeLoader, то есть yaml.safe_load. Подпись нужна, когда данные уходят к клиенту и возвращаются: документация pickle советует «signing data with hmac», а сравнивать подпись надо за постоянное время (hmac.Equal, hmac.compare_digest).

Как работают фильтры JEP 290спросят на собеседовании

JEP 290 вошёл в JDK 9 и дал ObjectInputStream точку контроля, которой раньше не было. До него оставалось переопределять resolveClass() в своём наследнике, и шпаргалка OWASP предупреждает, что такая проверка видит не всё: строки и прокси-классы идут мимо неё, а размер графа она не ограничивает.

Как устроен фильтр:

  • Это интерфейс ObjectInputFilter с методом checkInput. Он получает класс очередного объекта, длину массива, глубину графа, число ссылок и число прочитанных байт, а возвращает ALLOWED, REJECTED или UNDECIDED.
  • Для каждого нового объекта фильтр вызывается «before the object is instantiated and deserialized». Поэтому readObject отклонённого класса не запускается, в отличие от приведения типа после чтения.
  • Для массива фильтр получает класс массива и длину (шаблоны при этом сравнивают тип элемента), для ссылки на уже прочитанный объект - глубину, число ссылок и длину потока. Для примитивов и строк, записанных прямо в поток, он не вызывается, поэтому длину входа ограничивают ещё и снаружи.
  • Ставят фильтр на конкретный поток через setObjectInputFilter, как в примере выше, или на весь процесс: системным свойством jdk.serialFilter или одноимённым свойством в conf/security/java.security. Если задано системное свойство, оно важнее.
  • Шаблон - строка, части через ;. Лимиты maxdepth, maxrefs, maxbytes, maxarray проверяются раньше классов, где бы ни стояли. Классы сопоставляются слева направо: точное имя, пакет.* (классы пакета), пакет.** (с подпакетами), префикс*; ! в начале отклоняет. Если ни один шаблон не подошёл, статус UNDECIDED, поэтому белый список заканчивают на !*.

Чего JEP не делает, он перечисляет сам: не задаёт политику, какие классы разрешать («Define or maintain any specific policy of what classes should be allowed or disallowed» в списке Non-Goals), и не чинит сами классы. Список пишет команда, и лучше, если это список DTO конкретного входа, а не «весь наш пакет через .**»: пакет со временем пополнится классами, которые никто не проверял на роль гаджета. Для разных потоков в одном процессе есть продолжение, JEP 415 «Context-Specific Deserialization Filters».

И главное ограничение из шпаргалки: «Filtering does not make arbitrary untrusted deserialization safe». Фильтр - страховка для потоков, которые нельзя быстро убрать (внутренний протокол, старый кэш), а не разрешение принимать ObjectInputStream из интернета.

Pickle, yaml.load и сериализаторы на eval

pickle. Документация начинается с предупреждения: «It is possible to construct malicious pickle data which will execute arbitrary code during unpickling». Причина в самом формате: «By default, unpickling will import any class or function that it finds in the pickle data». В разделе Restricting Globals приведён поток cos\nsystem\n(S'echo hello world'\ntR. из нескольких десятков байт, который при pickle.loads вызывает os.system. Класс с __reduce__ в нашем примере - только удобный способ получить такой поток; атакующему класс не нужен, байты он пишет руками. То же касается обёрток: шпаргалка OWASP ставит jsonpickle.decode в один ряд с pickle.loads.

YAML. YAML выглядит как формат данных, но PyYAML поддерживает теги, которые создают объекты Python. CVE-2017-18342 как раз об этом: до 5.1 yaml.load() выполнял код из недоверенных данных, тег !!python/object/new:os.system из вики PyYAML запускает команду. С 5.1 вызов без Loader устарел, а по шпаргалке OWASP текущий PyYAML требует Loader явно. Правило простое: для чужого YAML только yaml.safe_load и никаких своих конструкторов, которые создают произвольные объекты. На маркетплейсе YAML приходит, например, в загружаемых правилах скидок или шаблонах интеграций продавцов.

Node. Восстановить функцию из строки можно, только выполнив эту строку как код. Библиотеки, которые обещают сериализовать «всё, включая функции», так и делают. В unserialize пакета node-serialize строка после префикса _$$ND_FUNC$$_ уходит в eval('(' + ... + ')'), а () в конце значения вызывает функцию прямо при разборе: это CVE-2017-5941 (NVD, 09.02.2017). Признак для ревью общий: eval или new Function рядом с разбором входных данных значит, что данные станут кодом.

Что делать вместо этого, во всех трёх языках одно и то же:

  • Внешние данные - в JSON. json.loads, JSON.parse и разбор в структуру не вызывают кода, а схема и отказ на неизвестных полях делают остальное.
  • Если формат унаследован, сужать его: find_class с коротким списком у pickle, SafeLoader у YAML, никаких библиотек, которые восстанавливают функции из строк.
  • Если данные возвращаются от клиента, подписывать: документация pickle прямо советует «signing data with hmac», и то же работает для любого формата.

Как проверить

Защита от XXE и десериализации держится на одной строке настройки, и её легко потерять при рефакторинге: кто-то создаёт фабрику парсера заново или добавляет новый вход. Поэтому проверка должна жить в тестах и правилах сборки, а не в памяти ревьюера.

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

  1. Тест создаёт временный файл с маркером, например xxe-canary-42, и строит документ с <!DOCTYPE feed [ <!ENTITY xxe SYSTEM "file:///путь/к/файлу"> ]> и &xxe; в названии товара. Так он не зависит от /etc/passwd и работает на любой ОС, как Java-пример выше.
  2. Документ уходит через тот же вход, что у продавца: HTTP-обработчик, консьюмер очереди, загрузчик файлов. Отдельно фид, накладная и SAML-ответ, если их разбирает разный код.
  3. Тест ждёт отказа (400 вместо 202 или исключение парсера) и проверяет, что маркера нет ни в базе, ни в ответе, ни в логах.
  4. Второй кейс - внешний DTD без сущностей в теле: <!DOCTYPE feed SYSTEM "http://127.0.0.1:<порт>/feed.dtd"> и тестовый сервер на этом порту, который считает обращения. Обращений должно быть ноль. Этот кейс ловит самодельные проверки по <!ENTITY.
  5. Третий - billion laughs из Python-примера: отказ должен прийти быстро, а не после того, как тест съест память.

Тест на фильтр десериализации. Для Java-потока сериализуют объект класса, которого нет в белом списке, и дают ему readObject, который выставляет флаг. Тест ждёт InvalidClassException и проверяет, что флаг не выставлен: значит, фильтр сработал до создания объекта, а не после. Отдельные кейсы на лимиты: массив длиннее maxarray, граф глубже maxdepth. Для pickle то же с find_class: поток, чей __reduce__ вызывает функцию-ловушку из теста. Для JSON-входов: лишнее поле, чужой type, строка вместо числа, и каждый кейс даёт отказ.

Правила SAST. Списки опасных вызовов из шпаргалок OWASP - готовые правила для статического анализа. Удобно держать два вида. Запрещающие: ObjectInputStream.readObject без фильтра, XMLDecoder, pickle.loads, yaml.load без безопасного загрузчика, enableDefaultTyping, eval при разборе данных. Требующие пары: создание DocumentBuilderFactory без disallow-doctype-decl рядом. Второй вид шумит, поэтому проще разрешить создание XML-парсеров только в одной общей фабрике и запретить прямой вызов везде, кроме неё.

Остальное, что стоит смотреть:

  • Поиск по коду. Шпаргалка OWASP перечисляет, что искать: ObjectInputStream с readObject, readUnshared, XMLDecoder, XStream.fromXML; в Python pickle.load(s), yaml.load с Loader=yaml.Loader или UnsafeLoader, yaml.unsafe_load, jsonpickle.decode. Добавьте enableDefaultTyping, activateDefaultTyping, JsonTypeInfo.Id.CLASS, DocumentBuilderFactory, SAXParserFactory, XMLInputFactory, TransformerFactory, SchemaFactory без рядом стоящей настройки и eval/new Function возле разбора данных в Node. Автоматизация этого поиска описана в статье про SAST.
  • По трафику. Признаки нативной сериализации из той же шпаргалки: AC ED 00 05 в hex и rO0 в base64 у Java, gASV в начале base64 у pickle, Content-Type: application/x-java-serialized-object.
  • Зависимости. Гаджеты живут в библиотеках, а уязвимые версии парсеров и сериализаторов ловит скан CVE: CVE в зависимостях.
  • В ревью. Каждый новый парсер XML создаётся через одну общую фабрику с запретом DTD. К каждому readObject, pickle.loads, yaml.load и разбору XML ревьюер задаёт одни и те же вопросы:
    • откуда пришли байты и кто мог их изменить по дороге;
    • кто выбирает тип объекта: код или поле в данных;
    • проверяется ли подпись до разбора, а не после;
    • есть ли лимит на размер и время разбора;
    • что попадёт в ответ или в лог, если разбор упадёт.

Типичные ошибки

  • Полумеры вместо запрета DTD. setExpandEntityReferences(false), по шпаргалке, управляет видом дерева, а не доступом к внешним ресурсам. Регулярка по <!ENTITY пропускает внешний DTD без единой сущности в тексте, что и показал уязвимый пример на Node.
  • Защитили один парсер. XSLT, проверка по XSD и XInclude загружают ресурсы сами, и настройки DocumentBuilderFactory на них не распространяются. То же с соседними сервисами: фид защищён, а импорт накладных написан отдельно и разбирает XML фабрикой по умолчанию.
  • Проверка типа после readObject. Код уже выполнился, приведение типа ничего не отменяет.
  • Чёрный список классов как основная защита. Новые гаджеты находят постоянно; нужен белый список или отказ от формата. Белый список вида ru.market.** тоже слабый: в пакет со временем попадёт что угодно.
  • «У нас JSON, значит безопасно». Пока не включён полиморфный режим, где класс выбирает поле из данных: default typing у Jackson, @type у json-io, autotype у fastjson2, свой реестр типов по строке из запроса.
  • «Это внутренняя очередь, там свои». Сообщение в очередь может положить любой сервис с доступом к ней, и одна взломанная служба превращает нативную сериализацию в выполнение кода во всех потребителях.
  • Подпись без проверки или с обычным ==. Подписанная cookie, которую сервер разбирает до сверки подписи, ничем не лучше неподписанной.
Дополнительно: при первом чтении можно пропустить

Глубже: почему в Go это другая историярасширенноеспросят на собеседовании

encoding/gob напоминает ObjectInputStream только снаружи: двоичный формат, который несёт описание типов.

Главное отличие в том, что gob не создаёт произвольные типы и не вызывает код по указке потока. Поля пишутся в уже объявленную переменную получателя; значения интерфейсов передаются под именем, которое надо заранее зарегистрировать через Register, а неизвестные имена не превращаются в новые типы. Сам Go не умеет создать тип по строке без явного реестра, поэтому цепочке гаджетов не из чего собраться.

Это не значит, что gob годится для чужих данных. В разделе Security пакета сказано: «This package is not designed to be hardened against adversarial inputs», декодер делает «only basic sanity checking on decoded input sizes», и его лимиты не настраиваются. То есть риск у gob другой: исчерпание памяти и CPU, а не выполнение кода. Вывод тот же: снаружи JSON с DisallowUnknownFields и лимитом размера тела, gob только между своими сервисами. А логическая ошибка «тип выбирает запрос», как в уязвимом примере с реестром, в Go воспроизводится так же легко, как в любом языке.

Стоит разделять, что в документации Go написано, а что выведено из устройства пакетов. Написано прямо: у encoding/gob есть раздел Security и фраза «Care should be taken when decoding gob data from untrusted sources, which may consume significant resources»; encoding/json при разборе в интерфейс кладёт туда только шесть видов значений, от bool до map[string]any; у xml.Decoder поле Strict «defaults to true», а Entity задаёт замены только для перечисленных в нём имён.

Не написано другое. У encoding/xml нет раздела Security, и документация нигде не обещает, что пакет не загружает внешние сущности или DTD. То, что видно в примере (DOCTYPE приходит токеном Directive, неизвестная сущность даёт ошибку), - наблюдение за текущей версией, а не гарантия. Поэтому явный отказ на DOCTYPE в коде фида стоит оставить: он дешёвый и не зависит от того, что изменится в стандартной библиотеке или в сторонней библиотеке, на которую сервис однажды перейдёт.

Глубже: XXE за пределами парсерарасширенное

Шпаргалка OWASP отдельно перечисляет места, где внешние ресурсы грузятся мимо настроек основного парсера:

  • XSLT тянет таблицы стилей через import, include и document(), поэтому у TransformerFactory выставляют ACCESS_EXTERNAL_DTD и ACCESS_EXTERNAL_STYLESHEET в пустую строку. Саму таблицу стилей выбирает код приложения, а не ссылка из входного документа.
  • XSD. Компиляция схемы и проверка по ней грузят импортированные схемы: у SchemaFactory пустые ACCESS_EXTERNAL_DTD и ACCESS_EXTERNAL_SCHEMA, а резолвер ставят и фабрике, и каждому Validator, потому что валидатор его не наследует.
  • XPath. XPath.evaluate с InputSource разбирает документ своим парсером, поэтому XPath применяют к документу, уже разобранному защищённым парсером.
  • JAXB получает защищённый XMLStreamReader, а не сырые байты.
  • XInclude держат выключенным.

И важная оговорка: «Preventing XXE does not prevent every XML denial-of-service attack», лимит размера тела и время разбора нужны отдельно.

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

Коротко

  • XXE (CWE-611, в Top 10:2025 внутри A02): внешняя сущность в DOCTYPE читает файлы сервера, делает SSRF и раздувает память.
  • Чинить запретом DTD: disallow-doctype-decl и FEATURE_SECURE_PROCESSING у DocumentBuilderFactory и SAX, SUPPORT_DTD=false и IS_SUPPORTING_EXTERNAL_ENTITIES=false у StAX, defusedxml с forbid_dtd=True в Python, отказ на DOCTYPE до сторонней библиотеки в Node.
  • Внешняя сущность заставляет парсер открыть адрес: file: делает его читалкой файлов, http: - HTTP-клиентом во внутренней сети. Точки входа на маркетплейсе: фид, накладные поставщиков, SAML-ответ.
  • Billion laughs (CWE-776) обходится без внешних ресурсов: вложенные внутренние сущности раздувают сотни байт до гигабайт. Запрет DTD закрывает и его.
  • Python stdlib не загружает внешние сущности, но раскрывает внутренние; Go encoding/xml отдаёт DOCTYPE токеном Directive и знает только сущности из Decoder.Entity.
  • Десериализация недоверенных данных (CWE-502, A08:2025 Software or Data Integrity Failures): нативный формат создаёт объекты и запускает их код до любой проверки.
  • Гаджет - чужой класс из зависимостей, который при восстановлении делает свою обычную работу с полями из потока; цепочка таких классов доходит до запуска команды. Обновление библиотеки закрывает цепочку, но не возможность.
  • JSON безопасен, пока класс не выбирают данные: default typing, @type, autotype и свои реестры типов по строке возвращают проблему.
  • Чинить отказом от нативных форматов снаружи: JSON в DTO, схема, отказ на неизвестных полях, тип не из запроса.
  • Если формат свой: подпись HMAC с проверкой до разбора, у Java ObjectInputFilter (JEP 290) с белым списком и лимитами, у pickle find_class без глобалов.
  • Фильтр JEP 290 вызывается до создания объекта, проверяет классы и лимиты, но политику не задаёт и чужой поток безопасным не делает.
  • Jackson: JsonTypeInfo.Id.NAME с перечислением подтипов, не enableDefaultTyping; YAML только safe_load; eval или new Function при разборе данных в Node - это выполнение кода.
  • Проверять тестами на каждый вход: документ с DOCTYPE и маркером из временного файла, внешний DTD со счётчиком обращений, класс вне белого списка с флагом в readObject; плюс правила SAST на опасные вызовы.
  • Log4Shell это JNDI-подстановка, а не десериализация; gob в Go не выполняет код, но и против враждебного ввода не защищён.

Что почитать дальше

  • SSRF - что сервер может сделать с адресом, который ему подсунули, и как держать список разрешённых хостов.
  • CVE в зависимостях - как ловить уязвимые версии парсеров и библиотек с гаджетами.
  • SAST по коду - автоматический поиск опасных вызовов в сборке.
  • Где какая проверка - раздел «Глубже: дыры помимо доступа» о десериализации рядом с инъекциями и обходом пути.
  • Service-to-service аутентификация - кто вообще может положить сообщение в очередь между сервисами.
  • Криптография в коде - ключи для HMAC-подписи и где их хранить.