По следам простреленных ног в парсерах Go (и не только)

По следам простреленных ног в парсерах Go (и не только)

https://t.me/art_code_ai

На днях Trail of Bits разродились в своём блоге статьей Unexpected security footguns in Go's parsers, посвященной не вполне очевидным проблемам, возникающим при парсинге структурированных форматов данных. Если одним предложением, то дизайн парсеров JSON, XML и YAML в Go содержит несколько подводных камней, хоть и упрощающих жизнь разработчика, но и открывающих возможности для атакующего. Статья действительно интересная, отбирать читателей у ребят из Trail of Bits желания нет, поэтому за подробностями — welcome по ссылке выше. Но, чтобы было понятно, о каких подводных камнях идёт речь, приведу несколько CVE, упомянутых в той статье:


- CVE‑2020‑16250 описан обход аутентификации в HashiCorp Vault: злоумышленник заставил сервер разобрать JSON там, где ожидался XML. Поскольку XML-парсер Go чрезвычайно терпим к формату (он извлекает любые XML-подобные фрагменты), подмена значения заголовка Accept на application/json привела к получению доступа без должной проверки.

- CVE‑2017‑12635 в Apache CouchDB: различия JSON-парсеров Erlang и JavaScript позволяли создать пользователя с двумя полями roles, где второе содержало "_admin". В итоге атакующий мог выдать себе админ-права (и далее ещё и провести RCE благодаря CVE‑2017‑12636).

- CVE‑2024‑34155 показала, что парсер Go можно обрушить (DoS) чрезмерно вложенным вводом: отсутствие контроля глубины разбора вызывало переполнение стека и панику ядра.


Но поговорить хотелось бы вот о чём... а почему собственно только Go? Другие языки — хуже, что-ли? Так, навскидку:


- Python: библиотека PyYAML до версии 5.1 могла выполнить через десериализацию произвольный код при разборе YAML через функции yaml.load и yaml.load_all (CVE-2019-20477)

- Java: парсер SnakeYAML до 1.31 не ограничивал глубину структуры, позволяя DoS через глубоко вложенный YAML (CVE‑2022‑25857).

- JavaScript: где он, там и Prototype Pollution. CVE‑2024‑38984 в модуле `json-override` через ключ `__proto__` позволяла «загрязнить» прототип объекта, приводя к выполнению кода.

- C#: за прошлогодние DoS в Newtonsoft.Json (CVE-2024-21907 и ах, если бы это была её единственная CVE...) и в стандартном System.Text.Json (CVE-2024-43485) скромно промолчим.


И это так, если по верхам брать. Но серьёзно, какие проблемы, аналогичные рассмотренным в оригинальной статье, присутствуют в других языках и их экосистемах?


Доступ к скрытым или игнорируемым полям при (де)сериализации


Python + PyYAML: в Python «приватные атрибуты» (начинающиеся с __) не защищены от сериализации. Например, PyYAML при сериализации объекта включает даже «приватные» поля:

import yaml

class User:
  def __init__(self, username, password):
    self.username = username
    self.__password = password

u = User("alice", "s3cr3t")
print(yaml.dump(u))

Получаем:

!!python/object:__main__.User
_User__password: s3cr3t
username: alice


Java + Gson: в Gson по умолчанию сериализуются все поля объекта, даже приватные. Например:

import com.google.gson.Gson;

class User {
  public String login = "admin";
  private String password = "secret";
}

User user = new User();
String json = new Gson().toJson(user);
System.out.println(json);

Получаем:

{"login":"admin","password":"secret"}

Игнорирование неизвестных полей

Java + Gson: при десериализации JSON, Gson по умолчанию пропускает неизвестные ключи без ошибок. Например, если есть класс:

class User { String name; }

и мы пытаемся распарсить в него JSON {"name": "Bob", "age": 30}, лишнее поле age будет тихо проигнорировано:

User u = new Gson().fromJson(jsonString, User.class);
System.out.println(u.name); 

Получаем: "Bob", без каких-либо ошибок / исключений


Python + Pydantic: библиотека Pydantic по умолчанию тоже игнорирует лишние поля во входных данных. Например:

from pydantic import BaseModel

class User(BaseModel):
  name: str

data = User.parse_obj({"name": "Bob", "admin": True})
print(data.dict())

Так же, по-тихому, получаем: {'name': 'Bob'}.

Дублирующиеся ключи в объектах

Здесь прямая аналогия с HTTP Parameter Pollution, и с примерно теми же последствиями при неправильно реализованной или отсутствующей семантической валидации.


JavaScript + JSON.parse: стандартный парсер JSON в JavaScript при дублировании полей оставляет последнее встреченное значение. Например:

let obj = JSON.parse('{"role": "user", "role": "admin"}');
console.log(obj); 

Получаем: { role: "admin" }


Python + `json`/PyYAML: аналогично, Python-парсер JSON и PyYAML берут последнее значение при повторе ключа:

import json, yaml
print(json.loads('{"x": 1, "x": 2}'))  # {'x': 2}
print(yaml.safe_load("x: 1\nx: 2\n"))  # {'x': 2}

Оба возвращают {'x': 2} без каких-либо ошибок.

Нечувствительность парсера к регистру ключей

А вот это — страх и ненависть уже синтаксической валидации.


.NET + Newtonsoft.Json: по умолчанию десериализует свойства класса независимо от регистра имени. Например, C# класс:

class User { public string Name {get;set;} }

и JSON: {"name": "Alice"} – будет корректно десериализован в User.Name = "Alice", хотя регистр не совпадает. Причем тут умножаем на вариацию предыдущей проблемы: если в JSON будет и "Name": "Eve", и "name": "Alice", то первая из них может быть перезаписана второй, т.к. парсер считает их одним свойством, а вот валидатор — далеко не факт.

Вложенные и «полиглотные» форматы данных

Самое понравившееся, поскольку частично является проблемой формата YAML, а не конкретной реализации его парсера 😊 Рассмотрим её подробнее.


JSON внутри YAML: YAML является супермножеством JSON, поэтому любой JSON-документ валиден как YAML. Многие YAML-парсеры (PyYAML, js-yaml и др.) спокойно примут строку в формате JSON:

import yaml
yaml_data = yaml.safe_load('{"flag": false, "value": 42}')
print(yaml_data)

Получаем:

{'flag': False, 'value': 42}

Это значит, что если сервис ожидает YAML, то злоумышленник может отправить JSON, и парсер обработает его без ошибок. Если при этом в YAML-обработчике включены какие-то специфичные для YAML особенности (например, типизация строк), они не сработают на JSON-вводе, что может быть использовано для обхода валидации.


Ребята из Trail of Bits показали, как составить вход, который одновременно распознаётся и JSON-, и YAML-, и XML-парсером, но дает разные значения. Идея в том, чтобы использовать вышеперечисленные особенности:

JSON-парсер проигнорирует незнакомые поля и допустит дубликаты, сопоставляя поля без учета регистра – поэтому из нескольких вариантов ключей он возьмет последний подходящий.

YAML-парсер (работающий в строгом регистре) проигнорирует ключ, не совпадающий точно с именем поля структуры, и возьмет другое значение.

XML-парсер может найти спрятанный XML-тег внутри строки JSON.


В примере Go-полиглота из статьи, JSON содержал значение поля, начинающееся с <Action3>...</Action3>. Стандартный парсер XML пропустил весь окружающий текст, и извлек содержимое этого XML-тега. В результате один и тот же байтовый поток интерпретировался как действие 2 для JSON, действие 1 для YAML и действие 3 для XML. Такой трюк применим и в других экосистемах: например, если одно приложение читает конфиг как JSON, а другое ошибочно как YAML, или когда данные передаются через несколько сервисов с разными форматами. Злоумышленник может создать двойной формат – например, завернуть JSON в комментарий XML, либо вставить валидный XML-фрагмент в значение JSON – чтобы обмануть систему. Реальный пример – упомянутая уязвимость в HashiCorp Vault, где запрос, содержащий одновременно JSON и XML, обходил аутентификацию из-за разных трактовок на разных этапах.

Чрезмерно толерантный парсинг нестрогого формата


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


Trailing/leading garbage (лишние данные вне структуры): Некоторые парсеры принимают «мусор» до или после основного документа. Старые версии PHP json\_decode ранее допускали трailing символы после JSON, а браузеры принимали JSON с завершающим `)` или `;` как часть JSONP. Известный трюк с CSRF через JSON эксплуатировал толерантность парсеров: отправлялся JSON с лишним символом = в конце и заголовком Content-Type: application/x-www-form-urlencoded – некоторые серверы игнорировали Content-Type и разбирали тело как JSON, не замечая лишний знак =, что обходило ограничения простых запросов SOP.


Java + Jackson: Библиотека Jackson в стандартном режиме читает JSON-поток и может не ругаться, если после корректного JSON-объекта в потоке идут другие данные, пока не включён режим строгости.


Почему это является проблемой? Избыточно «всеядный» парсер открывает возможности для атак в обход протокола. Лишние данные в конце могут быть использованы для скрытия второго запроса или полезной нагрузки (например, JSON плюс SQL-инъекция после него, где JSON-парсер съест своё, а SQL – своё, если данные проходят через разные слои). Лишние данные в начале (пробелы, BOM, комментарии – что не по стандарту JSON) тоже могут игнорироваться некоторыми реализациями JSON5/YAML, что приводит к расхождениям. Кроме того, сверхтолерантность к формату зачастую сопровождается ещё и ошибками реализации. Парсеры на неуправляемых языках вроде C/C++ с подобной гибкостью также могут содержать off-by-one баги или переполнения, приводящие к DoS или RCE при специально сформированном неверном JSON.

Правило простое: если вход не соответствует ожидаемому (и строгому) формату – лучше немедленно отвергнуть его, чем пытаться корректировать или отбрасывать мусор.

А разработчикам делать-то что?

Авторы приводят в конце статьи дельный набор советов, немного его дополню:


1. Включать строгую проверку (strict mode) там, где это возможно

JSON: явно запрещать все неизвестные поля (DisallowUnknownFields в Go, FAIL_ON_UNKNOWN_PROPERTIES в Jackson, strict=True в Pydantic).

YAML: включать режим строгого сопоставления (KnownFields(true) в Go, safe_load в PyYAML), запрещать автозагрузку кастомных объектов.

XML: валидировать по схеме (XSD/DTD).


2. Контролировать глубину и размер

Устанавливать лимиты на глубину вложенности, размер документа и число одновременно выделяемых сущностей. Многие парсеры позволяют отключить entity expansion либо настроить таймауты/лимиты. Btw, в Go (вроде и в некоторых других языках) YAML-парсеры уже начали отдавать ошибку при чрезмерном разворачивании ссылок.


3. Обеспечивать консистентность на всех интерфейсах

– Должен использоваться одинаковый парсер на разных компонентах системы (сервер, клиент, промежуточные сервисы), или хотя бы парсеры, строго реализующую одну и ту же спецификацию, чтобы не было рассогласования в их поведении.

– Если сервис принимает JSON и YAML — необходимо четко определять ожидания, и не позволять смешивать форматы без необходимости и адекватной валидации. Да банально: если на входе ожидается YAML, проверять, не парсится ли он без ошибок JSON-парсером, и бить тревогу, если это оказалось так.


4. Валидировать результаты парсинга

– Не доверять структуре после parse(): объект не является безопасным только потому, что был создан в результате парсинга входных данных. Необходимо подвергать ео семантической валидации в соответствии с правилами бизнес-логики, а также проверять, что отсутствуют лишние ключи, пересечения с __proto__, значение null и т.п. там, где это не ожидается.

– В JavaScript проверять наличие свойств через Object.prototype.hasOwnProperty.call(...), чтобы избежать Prototype Pollution.


5. Отказывать на любой мусор в данных

– Парсер должен разобрать весь вход: не следует допускать trailing/leading garbage, комментариев в JSON, незакрытых структур и т.д.

– Любое несоответствие данных ожидаемому формату — выброшенное исключение или сообщение об ошибке.


6. Избегать автосериализации приватных/скрытых полей

– В Java/Gson, C#/Newtonsoft, Python/PyYAML отключать сериализацию приватных полей по умолчанию.

– Использовать явное указание разрешённых полей (@Expose, JsonProperty, IncludeFields, Config.allowed_fields) и не доверять ObjectMapper.


7. Использовать статический анализ и автоматические проверки

– Подключить Semgrep и другие SAST‑инструменты, правилом блокирующие omitempty, -‑теги, permissive parsing и ненужные entity в XML/YAML (пример такого правила приведен в оригинальной статье).

– Проводить fuzz‑тестирование используемых парсеров (даже сторонних) с глубокими и полиглотными входами.


8. Документировать форматы и внедрять схемы

– Для JSON использовать JSON Schema, для YAML – JSON Schema совместимую с YAML, для XML – XSD/DTD.

– Встроить автоматическую актуализацию используемых схем в CI/CD пайплайн.

На правах эпилога...

Кто-то сказал «статический анализ»?! 🤩 Но серьезно, что здесь может предложить SAST? Проверить корректность конфигурации парсера? Да, если в его правилах есть экспертиза по конкретной реализации конкретной версии. Но это дело наживное, ок. Убедиться, что контроль грамматики входных данных не позволяет пробросить XML внутри JSON под видом YAML? Ну, смогут (чисто теоретически), но уже далеко не все анализаторы. Зато поискать сомнительные теги в декларациях структуры, как предлагаемое авторами правило Semgrep – вообще легко.

Но почему тогда перед этим правилом авторы делают такую жирную оговорку о false-positive'ах? Да потому, что ни один анализатор не выведет по коду, что именно хотел реализовать разработчик: какова ожидаемая схема разбираемого документа и каким требованиям (и какой) бизнес логики должны соответствовать его сущности. Здесь нужны четкие спецификации того «как должно быть», которые не любят писать разработчики и не умеют читать классические анализаторы. И тут сама собой напрашивается мысль: «но ведь LLM-то уже – ого-го! Наверняка же смогёт, догадается из общего кодового контекста, ведь там AGI уже, ну... почти?».

И вот об этом – мы обязательно скоро поговорим...


(Диз)лайкнуть и обсудить можно здесь ☺️

Report Page