stihl не предоставил(а) никакой дополнительной информации.
Парсинг ненадежных данных создает идеальную лазейку для атак на приложения, написанные на Go. В ходе наших исследований безопасности мы не раз находили уязвимости в парсерах JSON, XML и YAML Go, которые позволяли обходить аутентификацию, нарушать авторизационные правила и выкачивать чувствительные данные прямо с рабочих систем.
Это близкий к тексту пересказ статьи «Unexpected security footguns in Go’s parsers» из блога Trail of Bits. Ее автор — Васко Франко. Материал доступен без платной подписки.
Это не просто теоретические баги — они уже привели к реальным уязвимостям, таким как Для просмотра ссылки Войдиили Зарегистрируйся (обход аутентификации в HashiCorp Vault, который обнаружили ребята из Google Project Zero), и множеству критических находок в проектах наших клиентов.
Здесь мы расскажем об этих неожиданных глюках парсеров и приведем три сценария атаки, которые должен знать каждый инженер по безопасности и разработчик на Go.
Вот краткий обзор неожиданных поведений, которые мы рассмотрим, с индикаторами.
В своей основе эти парсеры выполняют две главные функции:
или Зарегистрируйся
В Go есть фишка — теги полей структур, позволяющие настроить, как парсеры должны работать с отдельными полями. Вот из чего состоят эти теги:
Чтобы распарсить JSON-строку в структуру User, описанную выше, тебе нужно использовать ключ username_json_key для поля Username, password для поля Password, а для поля IsAdmin — ключ is_admin.
Эти парсеры также поддерживают потоковые методы, работающие с интерфейсами io.Reader вместо byte-срезов. Такой API особенно хорош для парсинга потоковых данных, например тел HTTP-запросов, и поэтому часто применяется в системах обработки HTTP.
Парсинг JSON в Go с помощью NewDecoder
Давай разберем простой пример: есть бэкенд‑сервер, на котором настроен HTTP-хендлер для создания пользователей и еще один — для их извлечения после аутентификации.
Когда создаешь пользователя, возможно, тебе не захочется, чтобы он мог задать поле IsAdmin сам (то есть парсить это поле из пользовательского ввода).
Взаимодействие с бэкенд‑сервером, где пользователь может изменить поле IsAdmin в структуре User, — такую возможность давать нельзя
Точно так же при получении данных пользователя тебе может не понадобиться возвращать его пароль или другие секретные значения.
Взаимодействие с сервером, где пользователь каким‑то образом получает доступ к полю Password в структуре User, хотя это должно быть невозможно
Как можно указать парсерам не сериализовать или десериализовать поле?
В этом случае ты можешь распаковать поле Username по его имени, как показано ниже.
Это хорошо задокументировано, и большинство разработчиков на Go об этом знают. Давай глянем на другой пример:
Очевидно ли, что поле IsAdmin выше будет десериализовано? Менее опытный или невнимательный разработчик может подумать, что этого не произойдет, и так в системе образуется уязвимость.
Если хочешь просканировать свой код и обнаружить ситуации, когда только часть полей имеет JSON-, XML- или YAML-теги, используй следующее Semgrep-правило. Оно не попало в наш Для просмотра ссылки Войдиили Зарегистрируйся, так как в зависимости от твоего кода велика вероятность получить кучу ложных срабатываний.
Поехали!
Вот незадача, мы все‑таки смогли установить поле IsAdmin. Мы по ошибке скопировали часть ,omitempty, из‑за чего парсер начал искать ключ - в предоставленном JSON. Я пробежался по топ-1000 репозиториев на Go на GitHub с наибольшим количеством звезд и среди прочих нашел два таких случая (и отрапортовал о них, так что их уже исправили):
или Зарегистрируйся.
Парсеры для XML и YAML работают похоже, но есть один подвох: XML-парсер считает тег <-> некорректным. Чтобы это исправить, нужно добавить для символа «минус» пространство имен. Например, превратить его в <A:->.
Распаковываем поле с тегом - в форматах JSON, XML и YAML
Хорошо, давай на этот раз все сделаем правильно.
Наконец‑то! Теперь нет никакой возможности для десериализации поля IsAdmin.
Ты, наверное, спросишь: как же эти неправильные настройки могут превратиться в уязвимости в безопасности? Самый банальный способ — это, как в нашем примере, использовать -,... как JSON-тег для поля типа IsAdmin, которое пользователь ни в коем случае не должен контролировать.
Детектить такую штуку обычными юнит‑тестами очень сложно, ведь без специального теста, который распаковывает данные с ключом - и проверяет, изменилось ли поле, ты такой баг не поймаешь. Тут нужны либо какие‑то продвинутые фичи IDE, либо сторонний инструмент.
Взаимодействие с сервером, где пользователь может установить поле IsAdmin с помощью поля JSON
Мы разработали Для просмотра ссылки Войдиили Зарегистрируйся, которое поможет тебе выявить подобные проблемы в коде. Зацени его в деле:
Если ты установишь для JSON-тега omitempty, то парсер будет использовать omitempty как имя поля (что ожидаемо). Некоторые разработчики пытались так хитрить: ставить omitempty в качестве опции и при этом оставлять стандартное имя поля. Я пошерстил топ-1000 репозиториев на Go в поисках такого трюка, и вот что удалось нарыть:
В отличие от предыдущего примера, этот вряд ли повлияет на безопасность и должен легко выявляться в тестах. Любая попытка сериализовать или десериализовать ввод с ожидаемым именем поля провалится. Однако, как ни странно, такое все еще встречается даже в популярных опенсорсных репозиториях. Мы сделали Для просмотра ссылки Войдиили Зарегистрируйся, чтобы помочь тебе находить подобные баги в твоих проектах. Использовать так:
Возьмем для примера приложение, построенное на микросервисной архитектуре. В его составе:
Пользователь успешно проходит аутентификацию
Во втором сценарии тот же обычный пользователь пытается выполнить AdminAction — действие, которое ему категорически запрещено.
Пользователь пытается войти, но аутентификация проваливается
И вот происходит магия: сервисы начинают спорить о том, что же ты все‑таки пытаешься сделать.
Службы Proxy и Authorization не согласны в разборе пользовательского ввода, что создает уязвимость в потоке данных
Сервис авторизации, который написан на другом языке программирования или использует нестандартный парсер для Go, будет парсить UserAction и даст пользователю права на выполнение операции. А вот прокси‑сервис, который использует стандартный парсер Go, разберет AdminAction и отправит его не тому сервису. Остается вопрос: какие нагрузки мы можем создать, чтобы добиться такого поведения?
Это довольно популярная архитектура, которую нам доводилось встречать во время наших аудитов, и именно в ней мы обнаруживали обход аутентификации. Проблемы, о которых мы расскажем ниже, делают это возможным. Есть и другие примеры, но большинство из них следуют той же модели: компонент, отвечающий за проверку безопасности, и компонент, осуществляющий действия, по‑разному видят входные данные. Вот несколько таких примеров в различных сценариях:
В Go парсер JSON всегда возьмет последний элемент. И такую логику никак не поменять.
Это стандартное поведение большинства парсеров. Но, как Для просмотра ссылки Войдиили Зарегистрируйся ребята из Bishop Fox, 7 из 49 протестированных парсеров выбирают первый ключ:
Итак, если наш Proxy Service использует JSON-парсер на Go, а Authorization Service — один из перечисленных парсеров, мы получаем расхождение, как показано на картинке.
Сценарий атаки с использованием дублирующихся полей
XML-парсер ведет себя так же, в то время как YAML-парсер выдает ошибку при наличии дублирующихся полей. Мы считаем, что все подобные парсеры должны по умолчанию быть такими же безопасными.
Для просмотра ссылки Войдиили Зарегистрируйся
Хотя это и не идеально, но такое поведение по крайней мере соответствует большинству используемых парсеров JSON и XML. Теперь давай посмотрим на более серьезную проблему, которая почти всегда приводит к расхождениям между парсером Go по умолчанию и любым другим парсером.
Это документированное поведение, но крайне неочевидное. Отключить это невозможно, и почти ни один другой парсер так себя не ведет.
Мало того, еще и поля могут дублироваться и будет выбран последний вариант, даже если регистр букв отличается.
Это противоречит документации, где сказано:
Давай поглядим, как это будет выглядеть при атаке.
Сценарий атаки
На наш взгляд, это самый жесткий косяк JSON-парсера в Go, поскольку поведение отличается от поведения парсеров в JavaScript, Python, Rust, Ruby, Java и всех остальных, которые мы тестировали. В результате образовалась куча серьезных уязвимостей, включая те, что нам удалось вскрыть в ходе аудитов.
И последний штрих: отключить это поведение нельзя, несмотря на то что пользователи жалуются на Для просмотра ссылки Войдиили Зарегистрируйся уже с 2016 года.
Это касается только парсера JSON. Парсеры для XML и YAML используют точные совпадения.
Изображение, обобщающее работу всех трех парсеров
Если тебя интересуют различия в обработке JSON в разных парсерах, рекомендуем прочитать эти два поста:
Возьмем для примера Для просмотра ссылки Войдиили Зарегистрируйся: байпас защиты HashiCorp Vault в методе аутентификации через AWS IAM. Эту уязвимость обнаружила команда Google Project Zero (если интересно погрузиться в детали, в блоге есть пост под названием Для просмотра ссылки Войди или Зарегистрируйся). Мы не будем вываливать здесь все тонкости, но в целом вот как выглядит стандартный процесс аутентификации HashiCorp Vault через AWS IAM:
Процесс аутентификации с помощью Vault
Команда Google Project Zero обнаружила, что в шаге 2 злоумышленник может получить слишком большой контроль, включая задание всех заголовков запроса, которые Vault формирует на шаге 3. Особенно критично, что, установив заголовок Accept как application/json, AWS STS в шаге 5 вместо ожидаемого XML-документа возвращает JSON.
В итоге сервер Vault начинает парсить этот JSON с использованием XML-парсера на Go. А поскольку XML-парсер весьма снисходителен и умудряется разобрать почти все, что хоть отдаленно смахивает на XML, этот хаос из JSON становится достаточным для обхода аутентификации, если есть возможность хотя бы частично управлять JSON-ответом.
Процесс аутентификации Vault и уязвимость
Давай разберем три хитрости, позволяющие парсить файлы некорректным парсером Go, и создадим файл‑полиглот, который можно скормить парсерам JSON, XML и YAML. Каждому парсеру он выдаст свой уникальный результат.
Поведение парсеров JSON, XML и YAML при обработке неизвестных ключей
Поведение парсеров JSON, XML и YAML при наличии мусора в начале данных
Поведение парсеров JSON, XML и YAML при столкновении с мусорными данными в конце файла
Исключение составляет использование парсера Decoder API с потоковыми данными — в этом случае JSON-парсер примет мусорные данные в конце. Это Для просмотра ссылки Войдиили Зарегистрируйся, для которой пока нет запланированного решения.
Поведение парсеров JSON, XML и YAML при обработке мусора в конце данных с использованием API Decoder
Имея это в виду, мы можем замутить такой полиглот.
Файл‑полиглот и то, как разные парсеры его интерпретируют
Парсер JSON без проблем обрабатывает наш файл, ведь входные данные — это валидный JSON. Он просто игнорирует незнакомые ключи и позволяет их дублировать. Он выбирает значение Action_2, так как сравнение полей у него нечувствительно к регистру и берется значение последнего найденного совпадения.
Парсер YAML может обработать этот файл, потому что входные данные — это валидный JSON (а значит, и валидный YAML), а неизвестные ключи он просто игнорирует. Он цепляется за значение Action_1, поскольку, в отличие от парсера JSON, делает точное сопоставление по именам полей.
Наконец, парсер XML способен распознать наш формат, потому что игнорирует все лишнее и ищет лишь данные, которые напоминают XML. Мы спрятали их внутри значения JSON. В результате парсер выполняет Action_3.
Это мощная отправная точка для проведения атак на основе путаницы в форматах данных, аналогичных обходу HashiCorp Vault.
или Зарегистрируйся. Эта фича запрещает неизвестные поля во входящем JSON. YAML предлагает аналогичную функцию KnownFields(true). А вот для XML была попытка сделать нечто подобное, но Для просмотра ссылки Войди или Зарегистрируйся завернули.
Чтобы покончить с оставшимися косяками стандартных настроек безопасности, нам придется сочинить что‑то свое, кастомное и немного хакерское. Взгляни на следующий блок кода с функцией strictJSONParse. Это наша попытка сделать разбор JSON строже, хотя тут есть свои ограничения:
или Зарегистрируйся — новая версия библиотеки парсинга JSON для Go. Пока это только предложение, но огромная работа уже проделана, и мы надеемся, что совсем скоро этот стандарт выйдет. JSON v2 превосходит первую версию по многим параметрам:
Это близкий к тексту пересказ статьи «Unexpected security footguns in Go’s parsers» из блога Trail of Bits. Ее автор — Васко Франко. Материал доступен без платной подписки.
Это не просто теоретические баги — они уже привели к реальным уязвимостям, таким как Для просмотра ссылки Войди
Здесь мы расскажем об этих неожиданных глюках парсеров и приведем три сценария атаки, которые должен знать каждый инженер по безопасности и разработчик на Go.
- (Де)сериализация неожиданных данных: как парсеры на Go могут раскрыть данные, которые разработчики планировали оставить приватными.
- Различия парсеров: как несовпадения в работе разных парсеров позволяют хакерам обходить меры безопасности, когда несколько сервисов обрабатывают одинаковые данные.
- Путаница в форматах данных: как парсеры обрабатывают межформатные данные с неожиданными и порой весьма взрывоопасными результатами.
Вот краткий обзор неожиданных поведений, которые мы рассмотрим, с индикаторами.
Особенность | JSON | JSON v2 | XML | YAML |
---|---|---|---|---|
json:"-," | Да (плохой дизайн) | Да (плохой дизайн) | Да (плохой дизайн) | Да (плохой дизайн) |
json:"omitempty" | Да (как и ожидалось) | Да (как и ожидалось) | Да (как и ожидалось) | Да (как и ожидалось) |
Дублирующиеся ключи | Да (последний) | Нет | Да (последний) | Нет |
Регистронезависимость | Да | Нет | Нет | Нет |
Неизвестные ключи | Да (исправимо) | Да (исправимо) | Да | Да (исправимо) |
Лишние ведущие данные | Нет | Нет | Да | Нет |
Лишние данные в конце | Да (с Decoder) | Нет | Да | Нет |
Парсинг в Go
Давай разберем, как Go обрабатывает JSON, XML и YAML. В стандартной библиотеке Go найдутся парсеры для JSON и XML, а вот для YAML придется подобрать что‑то стороннее — на выбор есть куча реализаций. В этой статье мы сосредоточимся на таких парсерах:- Для просмотра ссылки Войди
или Зарегистрируйся, версия go1.24.1; - Для просмотра ссылки Войди
или Зарегистрируйся, версия go1.24.1; - Для просмотра ссылки Войди
или Зарегистрируйся, версия 3.0.1, самая популярная сторонняя библиотека YAML для Go.
В своей основе эти парсеры выполняют две главные функции:
- Marshal (сериализация) — превращает структуры Go в строки заданного формата;
- Unmarshal (десериализация) — конвертирует строки формата обратно в структуры Go.
В Go есть фишка — теги полей структур, позволяющие настроить, как парсеры должны работать с отдельными полями. Вот из чего состоят эти теги:
- имя ключа для сериализации/десериализации;
- опциональные директивы через запятую для изменения поведения (например, опция omitempty в JSON указывает сериализатору пропустить пустое поле в строке вывода).
Код:
type User struct {
// Поле для имени пользователя в JSON
Username string json:"username_json_key,omitempty"
// Пароль пользователя
Password string json:"password"
// Флаг для админа
IsAdmin bool json:"is_admin"
}
Чтобы распарсить JSON-строку в структуру User, описанную выше, тебе нужно использовать ключ username_json_key для поля Username, password для поля Password, а для поля IsAdmin — ключ is_admin.
Код:
u := User{}
_ = json.Unmarshal([]byte(`{
"username_json_key": "jofra",
"password": "qwerty123!",
"is_admin": "false"
}`), &u)
fmt.Printf("Result: %#v\n", u)
// Результат: User{Username:"jofra", Password:"qwerty123!", IsAdmin:false}
Эти парсеры также поддерживают потоковые методы, работающие с интерфейсами io.Reader вместо byte-срезов. Такой API особенно хорош для парсинга потоковых данных, например тел HTTP-запросов, и поэтому часто применяется в системах обработки HTTP.
Атака 1: подмена данных при (де)сериализации
Иногда нужно ограничить, какие именно поля структуры могут быть сериализованы или десериализованы.Давай разберем простой пример: есть бэкенд‑сервер, на котором настроен HTTP-хендлер для создания пользователей и еще один — для их извлечения после аутентификации.
Когда создаешь пользователя, возможно, тебе не захочется, чтобы он мог задать поле IsAdmin сам (то есть парсить это поле из пользовательского ввода).
Точно так же при получении данных пользователя тебе может не понадобиться возвращать его пароль или другие секретные значения.
Как можно указать парсерам не сериализовать или десериализовать поле?
Поля без меток
Давай сначала посмотрим, что произойдет, если ты не установишь тег JSON.
Код:
type User struct {
Username string
}
В этом случае ты можешь распаковать поле Username по его имени, как показано ниже.
Код:
_ = json.Unmarshal([]byte({"Username": "jofra"}), &u)
// Результат: User{Username:"jofra"}
Это хорошо задокументировано, и большинство разработчиков на Go об этом знают. Давай глянем на другой пример:
Код:
type User struct {
// Имя пользователя, если не заполнено — не отображается в JSON
Username string json:"username,omitempty"
// Пароль пользователя, тоже исчезающий в JSON, если пустой
Password string json:"password,omitempty"
// Флаг того, что пользователь — админ
IsAdmin bool
}
Очевидно ли, что поле IsAdmin выше будет десериализовано? Менее опытный или невнимательный разработчик может подумать, что этого не произойдет, и так в системе образуется уязвимость.
Если хочешь просканировать свой код и обнаружить ситуации, когда только часть полей имеет JSON-, XML- или YAML-теги, используй следующее Semgrep-правило. Оно не попало в наш Для просмотра ссылки Войди
Код:
rules:
- id: unmarshaling-tag-in-only-some-fields
message: >-
Тип $T1 имеет поля с тегами json/yml/xml только на некоторых полях, а не на всех. Такое поле все равно может быть (де)сериализовано по имени. Чтобы предотвратить (де)сериализацию поля, используй тег -.
languages: [go]
severity: WARNING
patterns:
- pattern-inside: |
type $T1 struct {
...
$_ $_ $TAG
...
}
# Этот регекс пытается избежать некоторых ложных срабатываний, таких как структуры, объявленные внутри структур
- pattern-regex: >-
^[ \t]+[A-Z]+[a-zA-Z0-9][ \t]+[a-zA-Z0-9]+[^{`\n\r]$
- metavariable-regex:
metavariable: $TAG
regex: >-
.*(json|yaml|xml):"[^,-
Неправильное использование тега -
Чтобы заставить парсер игнорировать конкретное поле при (де)сериализации, добавь спецтег JSON, который выглядит как знак минуса.
Код:
type User struct {
// Имя пользователя в JSON
Username string json:"username,omitempty"
// Пароль в JSON
Password string json:"password,omitempty"
// Админские права не добавляются в JSON
IsAdmin bool json:"-,omitempty"
}
Код:
_ = json.Unmarshal([]byte({"-": true}), &u)
// Результат: main.User{Username:"", Password:"", IsAdmin:true}
Вот незадача, мы все‑таки смогли установить поле IsAdmin. Мы по ошибке скопировали часть ,omitempty, из‑за чего парсер начал искать ключ - в предоставленном JSON. Я пробежался по топ-1000 репозиториев на Go на GitHub с наибольшим количеством звезд и среди прочих нашел два таких случая (и отрапортовал о них, так что их уже исправили):
- в Flipt поле ClientID в конфигурации OIDC отображалось как - (исправлено в Для просмотра ссылки Войди
или Зарегистрируйся); - в langchaingo поле MaxTokens также отображалось как - (исправлено в Для просмотра ссылки Войди
или Зарегистрируйся).
info
Особый случай: если у тега поля стоит -, то это поле всегда пропускается. Фокус в том, что поле с именем - все еще можно создать, используя тег -,.Парсеры для XML и YAML работают похоже, но есть один подвох: XML-парсер считает тег <-> некорректным. Чтобы это исправить, нужно добавить для символа «минус» пространство имен. Например, превратить его в <A:->.
Хорошо, давай на этот раз все сделаем правильно.
Код:
type User struct {
// Имя пользователя в JSON, если не пустое
Username string json:"username,omitempty"
// Пароль в JSON, если не пустой
Password string json:"password,omitempty"
// Не отображать признак админа в JSON
IsAdmin bool json:"-"
}
Наконец‑то! Теперь нет никакой возможности для десериализации поля IsAdmin.
Ты, наверное, спросишь: как же эти неправильные настройки могут превратиться в уязвимости в безопасности? Самый банальный способ — это, как в нашем примере, использовать -,... как JSON-тег для поля типа IsAdmin, которое пользователь ни в коем случае не должен контролировать.
Детектить такую штуку обычными юнит‑тестами очень сложно, ведь без специального теста, который распаковывает данные с ключом - и проверяет, изменилось ли поле, ты такой баг не поймаешь. Тут нужны либо какие‑то продвинутые фичи IDE, либо сторонний инструмент.
Мы разработали Для просмотра ссылки Войди
semgrep -c r/trailofbits.go.unmarshal-tag-is-dash
Ошибки с omitempty
Еще одна простая, но веселая ошибка конфигурации, с которой мы сталкивались раньше: разработчик по ошибке задал имя поля как omitempty.// Результат: User{Username:"a_user"}
Если ты установишь для JSON-тега omitempty, то парсер будет использовать omitempty как имя поля (что ожидаемо). Некоторые разработчики пытались так хитрить: ставить omitempty в качестве опции и при этом оставлять стандартное имя поля. Я пошерстил топ-1000 репозиториев на Go в поисках такого трюка, и вот что удалось нарыть:
- Gitea Для просмотра ссылки Войди
или Зарегистрируйся, выставляя наружу поле Args в структуре TranslatableMessage с припиской omitempty (залатано в пул‑реквесте Для просмотра ссылки Войдиили Зарегистрируйся). - Подобная история у Kustomize: у него Для просмотра ссылки Войди
или Зарегистрируйся поле Replacements в структуре plugin, тоже с omitempty (поправлено в Для просмотра ссылки Войдиили Зарегистрируйся). - Btcd тоже Для просмотра ссылки Войди
или Зарегистрируйся, выдав поле MaxFeeRate в структуре TestMempoolAcceptCmd с тем самым omitempty. - Evcc последовал той же дорожкой, Для просмотра ссылки Войди
или Зарегистрируйся поле Message в структуре Measurements с ключом omitempty.
В отличие от предыдущего примера, этот вряд ли повлияет на безопасность и должен легко выявляться в тестах. Любая попытка сериализовать или десериализовать ввод с ожидаемым именем поля провалится. Однако, как ни странно, такое все еще встречается даже в популярных опенсорсных репозиториях. Мы сделали Для просмотра ссылки Войди
semgrep -c r/trailofbits.go.unmarshal-tag-is-omitempty
Атака 2: разные парсеры — разные результаты
Что произойдет, если разобрать одни и те же данные с помощью разных JSON-парсеров и они выдадут разные результаты? И самое интересное — какие особенности парсеров в Go дают злоумышленникам возможность стабильно провоцировать такие расхождения?Возьмем для примера приложение, построенное на микросервисной архитектуре. В его составе:
- прокси‑сервис, который обрабатывает все запросы пользователей;
- сервис авторизации, на который прокси‑сервис ссылается, чтобы проверить, есть ли у пользователя нужные права для выполнения его запроса;
- набор сервисов бизнес‑логики, которые прокси‑сервис дергает, чтобы реализовать бизнес‑логику.
Во втором сценарии тот же обычный пользователь пытается выполнить AdminAction — действие, которое ему категорически запрещено.
И вот происходит магия: сервисы начинают спорить о том, что же ты все‑таки пытаешься сделать.
Сервис авторизации, который написан на другом языке программирования или использует нестандартный парсер для Go, будет парсить UserAction и даст пользователю права на выполнение операции. А вот прокси‑сервис, который использует стандартный парсер Go, разберет AdminAction и отправит его не тому сервису. Остается вопрос: какие нагрузки мы можем создать, чтобы добиться такого поведения?
Это довольно популярная архитектура, которую нам доводилось встречать во время наших аудитов, и именно в ней мы обнаруживали обход аутентификации. Проблемы, о которых мы расскажем ниже, делают это возможным. Есть и другие примеры, но большинство из них следуют той же модели: компонент, отвечающий за проверку безопасности, и компонент, осуществляющий действия, по‑разному видят входные данные. Вот несколько таких примеров в различных сценариях:
- Для просмотра ссылки Войди
или Зарегистрируйся: уязвимость обхода авторизации в Apache CouchDB из‑за различий JSON-парсеров (очень похоже на наш пример); - Для просмотра ссылки Войди
или Зарегистрируйся (2020); - удаленное выполнение кода в Zoom без взаимодействия пользователя из‑за различий XML-парсеров в XMPP (2022, Для просмотра ссылки Войди
или Зарегистрируйся); - Для просмотра ссылки Войди
или Зарегистрируйся (2025).
Дубли полей
Первая уязвимость, которую мы разберем, — это дублирование ключей. Что будет, если во входном JSON один и тот же ключ встречается дважды? А тут уже все зависит от парсера!В Go парсер JSON всегда возьмет последний элемент. И такую логику никак не поменять.
Код:
_ = json.Unmarshal([]byte(`{
"action": "Action1",
"action": "Action2"
}`), &a)
// Итог: ActionRequest{Action:"Action2"} — последний ключ побеждает!
Это стандартное поведение большинства парсеров. Но, как Для просмотра ссылки Войди
- Go: jsonparser, gojay;
- C++: rapidjson;
- Java: json-iterator;
- Elixir: Jason, Poison;
- Erlang: jsone.
Итак, если наш Proxy Service использует JSON-парсер на Go, а Authorization Service — один из перечисленных парсеров, мы получаем расхождение, как показано на картинке.
XML-парсер ведет себя так же, в то время как YAML-парсер выдает ошибку при наличии дублирующихся полей. Мы считаем, что все подобные парсеры должны по умолчанию быть такими же безопасными.
Для просмотра ссылки Войди
Хотя это и не идеально, но такое поведение по крайней мере соответствует большинству используемых парсеров JSON и XML. Теперь давай посмотрим на более серьезную проблему, которая почти всегда приводит к расхождениям между парсером Go по умолчанию и любым другим парсером.
Поиск по ключам без учета регистра
Парсер JSON в Go распознает имена полей без учета регистра. Пиши action как action, ACTION или aCtIoN — для парсера это все одно и то же!
Код:
_ = json.Unmarshal([]byte(`{
"aCtIoN": "Action2"
}`), &a)
// Результат: ActionRequest{Action:"Action2"}
Это документированное поведение, но крайне неочевидное. Отключить это невозможно, и почти ни один другой парсер так себя не ведет.
Мало того, еще и поля могут дублироваться и будет выбран последний вариант, даже если регистр букв отличается.
Код:
_ = json.Unmarshal([]byte(`{
"action": "Action1",
"aCtIoN": "Action2"
}`), &a)
// Результат: ActionRequest{Action:"Action2"}
Это противоречит документации, где сказано:
Ты можешь даже использовать символы Unicode! В примере ниже мы используем символ ſ (латинское долгое s) как s и K (знак Кельвина) как k. В нашем тестировании библиотеки JSON, которая выполняет сравнение, только эти два символа Unicode соответствуют ASCII-символам.Чтобы распарсить JSON в структуру, метод Unmarshal сопоставляет ключи входящего объекта с ключами, которые использует Marshal (либо имя поля структуры, либо его тег), предпочитая точное совпадение, но также принимая совпадение без учета регистра.
Код:
type ActionRequest struct {
Action string json:"aktions"
}
a := ActionRequest{}
_ = json.Unmarshal([]byte(`
{
"aktions": "Action1",
"aKtionſ": "Action2"
}
`), &a)
fmt.Printf("Result: %#v\n", a)
// Результат: main.ActionRequest{Action:"Action2"}
Давай поглядим, как это будет выглядеть при атаке.
На наш взгляд, это самый жесткий косяк JSON-парсера в Go, поскольку поведение отличается от поведения парсеров в JavaScript, Python, Rust, Ruby, Java и всех остальных, которые мы тестировали. В результате образовалась куча серьезных уязвимостей, включая те, что нам удалось вскрыть в ходе аудитов.
И последний штрих: отключить это поведение нельзя, несмотря на то что пользователи жалуются на Для просмотра ссылки Войди
Это касается только парсера JSON. Парсеры для XML и YAML используют точные совпадения.
Если тебя интересуют различия в обработке JSON в разных парсерах, рекомендуем прочитать эти два поста:
- Для просмотра ссылки Войди
или Зарегистрируйся (Николя Серио); - Для просмотра ссылки Войди
или Зарегистрируйся (Бишоп Фокс).
Атака 3: путаница в форматах данных
Для финальной атаки давай посмотрим, что будет, если распарсить JSON-файл XML-парсером или использовать какой‑нибудь другой неподходящий формат.Возьмем для примера Для просмотра ссылки Войди
- AWS-ресурс, скажем функция AWS Lambda, подписывает запрос Для просмотра ссылки Войди
или Зарегистрируйся. - Этот запрос отправляется на сервер Vault.
- Сервер Vault собирает запрос и пересылает его в AWS Security Token Service (STS).
- AWS STS проверяет подпись.
- Если все окей, AWS STS возвращает XML-документ с данными о роли.
- Сервер Vault парсит XML, извлекает идентификатор и, если у этой роли есть доступ к запрашиваемым секретам, отправляет их обратно.
- Теперь AWS-ресурс может использовать секреты, например чтобы авторизоваться в базе данных.
Команда Google Project Zero обнаружила, что в шаге 2 злоумышленник может получить слишком большой контроль, включая задание всех заголовков запроса, которые Vault формирует на шаге 3. Особенно критично, что, установив заголовок Accept как application/json, AWS STS в шаге 5 вместо ожидаемого XML-документа возвращает JSON.
В итоге сервер Vault начинает парсить этот JSON с использованием XML-парсера на Go. А поскольку XML-парсер весьма снисходителен и умудряется разобрать почти все, что хоть отдаленно смахивает на XML, этот хаос из JSON становится достаточным для обхода аутентификации, если есть возможность хотя бы частично управлять JSON-ответом.
Давай разберем три хитрости, позволяющие парсить файлы некорректным парсером Go, и создадим файл‑полиглот, который можно скормить парсерам JSON, XML и YAML. Каждому парсеру он выдаст свой уникальный результат.
Неизвестные ключи
По умолчанию парсеры для JSON, XML и YAML не блокируют неизвестные поля — то есть свойства во входящих данных, которые не соответствуют ни одному полю в целевой структуре.Мусорные данные в начале
Из трех парсеров только XML-парсер переваривает мусорные данные в начале.Мусорные данные в конце
Только XML-парсер может проглотить произвольный мусор в конце данных.Исключение составляет использование парсера Decoder API с потоковыми данными — в этом случае JSON-парсер примет мусорные данные в конце. Это Для просмотра ссылки Войди
Создаем полиглот
Как объединить все рассмотренные нами способы поведения, чтобы создать файл‑полиглот, который:- можно обработать парсерами JSON, XML и YAML на Go;
- возвращает разные результаты для каждого парсера?
info
Любой файл JSON — это одновременно и валидный файл YAML!Имея это в виду, мы можем замутить такой полиглот.
Парсер JSON без проблем обрабатывает наш файл, ведь входные данные — это валидный JSON. Он просто игнорирует незнакомые ключи и позволяет их дублировать. Он выбирает значение Action_2, так как сравнение полей у него нечувствительно к регистру и берется значение последнего найденного совпадения.
Парсер YAML может обработать этот файл, потому что входные данные — это валидный JSON (а значит, и валидный YAML), а неизвестные ключи он просто игнорирует. Он цепляется за значение Action_1, поскольку, в отличие от парсера JSON, делает точное сопоставление по именам полей.
Наконец, парсер XML способен распознать наш формат, потому что игнорирует все лишнее и ищет лишь данные, которые напоминают XML. Мы спрятали их внутри значения JSON. В результате парсер выполняет Action_3.
Это мощная отправная точка для проведения атак на основе путаницы в форматах данных, аналогичных обходу HashiCorp Vault.
Способы защиты
Как свести риск к минимуму и сделать парсинг JSON более строгим? Мы бы хотели:- не допускать разбора неизвестных ключей в JSON, XML и YAML;
- не допускать разбора дублирующихся ключей в JSON и XML;
- исключить учет регистра ключей в JSON (это особенно важно!);
- избегать мусора в начале XML;
- избегать мусора в конце JSON и XML.
Чтобы покончить с оставшимися косяками стандартных настроек безопасности, нам придется сочинить что‑то свое, кастомное и немного хакерское. Взгляни на следующий блок кода с функцией strictJSONParse. Это наша попытка сделать разбор JSON строже, хотя тут есть свои ограничения:
- Плохая производительность: JSON приходится парсить дважды, что заметно замедляет процесс.
- Неполная детекция: в некоторых крайних случаях недочеты все‑таки остаются, как указано в комментариях к функции.
- Низкий потенциал внедрения: эти меры безопасности не встроены в библиотеки как защищенные настройки по умолчанию или настраиваемые опции, так что массовое распространение вряд ли получится.
Код:
// DetectCaseInsensitiveKeyCollisions проверяет, есть ли в JSON-данных ключи, которые различаются только регистром букв. Это помогает предотвратить скрытые баги, где два ключа с разным написанием могут ссылаться на одни и те же данные.
func DetectCaseInsensitiveKeyCollisions(data []byte) error {
// Создаем карту для хранения декодированных JSON-данных и пытаемся распарсить JSON. Это сохраняет ключи с разным регистром.
var res map[string]interface{}
if err := json.NewDecoder(bytes.NewReader(data)).Decode(&res); err != nil {
return err
}
seenKeys := make([]string, 0, len(res))
// Пробежимся по всем ключам в распарсенном JSON и поищем дубликаты
for newKey := range res {
for _, existingKey := range seenKeys {
if strings.EqualFold(existingKey, newKey) {
// Вернуть ошибку, если найден дубликат без учета регистра
return fmt.Errorf("найдены дубликаты ключей без учета регистра:
%q и %q", existingKey, newKey)
}
}
seenKeys = append(seenKeys, newKey)
}
return nil
}
// Обеспечивает более строгий парсинг JSON с дополнительной проверкой:
// 1. Отвергает неизвестные поля, которых нет в целевой структуре
// 2. Выявляет дубликаты ключей без учета регистра
// 3. Проверяет полный парсинг без остаточных данных
// strictJSONParse НЕ делает следующее:
// - Не гарантирует отсутствие дублирующих ключей с одинаковым регистром
// - Не проверяет, совпадает ли регистр во входных данных с ожидаемым регистром в целевой структуре
func strictJSONParse(jsonData []byte, target interface{}) error {
decoder := json.NewDecoder(bytes.NewReader(jsonData))
// 1. Запретить неизвестные поля
decoder.DisallowUnknownFields()
// 2. Запретить дублирующиеся ключи с разным регистром
err := DetectCaseInsensitiveKeyCollisions(jsonData)
if err != nil {
return fmt.Errorf("strictJSONParse: %w", err)
}
// Декодируем JSON в переданную структуру
err = decoder.Decode(target)
if err != nil {
return fmt.Errorf("strictJSONParse: %w", err)
}
// 3. Убедимся, что нет остаточных данных после JSON-объекта
token, err := decoder.Token()
if err != io.EOF {
return fmt.Errorf("strictJSONParse: неожиданные дополнительные данные после JSON: token: %v, err: %v", token, err)
}
return nil
}
JSON 2.0
Чтобы фича стала массовой и действительно решила проблему на глобальном уровне, ее нужно вшить в библиотеку и включить по умолчанию. Вот тут‑то и вступает в игру Для просмотра ссылки Войди- Запрещены дублирующиеся имена: «...в версии v2 JSON-объект с дублирующимися именами приводит к ошибке. Поведение регулируется опцией jsontext.AllowDuplicateNames».
- Учитывается регистр при совпадении: «...в v2 поля совпадают точно, с учетом регистра. Опции MatchCaseInsensitiveNames и jsonv1.MatchCaseSensitiveDelimiter контролируют это поведение».
- Есть опция RejectUnknownMembers, хотя она не включена по умолчанию (аналогична DisallowUnknownFields).
- Есть возможность обрабатывать данные из io.Reader с помощью функции UnmarshalRead, проверяя наличие EOF и не допуская лишних данных в конце.
Памятка для разработчика
- Включи строгий парсинг по умолчанию. Для JSON используй DisallowUnknownFields, для YAML — KnownFields(true). Увы, это все, что реально можно сделать с API парсера напрямую в Go.
- Сохраняй консистентность на границах. Когда данные проходят через несколько сервисов, убедись, что парсинг работает последовательно, — используй одинаковый парсер или добавь дополнительные уровни валидации, например такую штуку, как strictJSONParse.
- Следи за прогрессом JSON v2. Поглядывай за разработкой библиотеки Для просмотра ссылки Войди
или Зарегистрируйся для Go, которая решает массу проблем, предлагая более безопасные дефолты. - Используй статический анализ. Врубай правила Semgrep, чтобы выловить уязвимые паттерны в своем коде, особенно некорректное использование тега - и полей omitempty. Попробуй запустить созданные нами правила.