Пар­синг ненадеж­ных дан­ных соз­дает иде­аль­ную лазей­ку для атак на при­ложе­ния, написан­ные на Go. В ходе наших иссле­дова­ний безопас­ности мы не раз находи­ли уяз­вимос­ти в пар­серах JSON, XML и YAML Go, которые поз­воляли обхо­дить аутен­тифика­цию, нарушать авто­риза­цион­ные пра­вила и выкачи­вать чувс­тви­тель­ные дан­ные пря­мо с рабочих сис­тем.

Это близ­кий к тек­сту перес­каз статьи «Unexpected security footguns in Go’s parsers» из бло­га Trail of Bits. Ее автор — Вас­ко Фран­ко. Матери­ал дос­тупен без плат­ной под­писки.

Это не прос­то теоре­тичес­кие баги — они уже при­вели к реаль­ным уяз­вимос­тям, таким как CVE-2020-16250 (обход аутен­тифика­ции в HashiCorp Vault, который обна­ружи­ли ребята из Google Project Zero), и мно­жес­тву кри­тичес­ких находок в про­ектах наших кли­ентов.

Здесь мы рас­ска­жем об этих неожи­дан­ных глю­ках пар­серов и при­ведем три сце­нария ата­ки, которые дол­жен знать каж­дый инже­нер по безопас­ности и раз­работ­чик на Go.

  1. (Де)сери­али­зация неожи­дан­ных дан­ных: как пар­серы на Go могут рас­крыть дан­ные, которые раз­работ­чики пла­ниро­вали оста­вить при­ват­ными.
  2. Раз­личия пар­серов: как несов­падения в работе раз­ных пар­серов поз­воля­ют хакерам обхо­дить меры безопас­ности, ког­да нес­коль­ко сер­висов обра­баты­вают оди­нако­вые дан­ные.
  3. Пу­тани­ца в фор­матах дан­ных: как пар­серы обра­баты­вают меж­формат­ные дан­ные с неожи­дан­ными и порой весь­ма взры­воопас­ными резуль­татами.

Мы покажем каж­дый сце­нарий ата­ки на при­мере из реаль­ной жиз­ни и завер­шим все кон­крет­ными рекомен­даци­ями о том, как безопас­нее исполь­зовать пар­синг. В том чис­ле рас­ска­жем, как затыкать дыры в безопас­ности стан­дар­тной биб­лиоте­ки Go.

Вот крат­кий обзор неожи­дан­ных поведе­ний, которые мы рас­смот­рим, с инди­като­рами.

Осо­бен­ность JSON JSON v2 XML YAML
json:"-," Да
(пло­хой дизайн)
Да
(пло­хой дизайн)
Да
(пло­хой дизайн)
Да
(пло­хой дизайн)
json:"omitempty" Да
(как и ожи­далось)
Да
(как и ожи­далось)
Да
(как и ожи­далось)
Да
(как и ожи­далось)
Дуб­лиру­ющиеся клю­чи Да
(пос­ледний)
Нет Да
(пос­ледний)
Нет
Ре­гис­тро­неза­виси­мость Да Нет Нет Нет
Не­извес­тные клю­чи Да
(испра­вимо)
Да
(испра­вимо)
Да Да
(испра­вимо)
Лиш­ние ведущие дан­ные Нет Нет Да Нет
Лиш­ние дан­ные в кон­це Да
(с Decoder)
Нет Да Нет
 

Парсинг в Go

Да­вай раз­берем, как Go обра­баты­вает JSON, XML и YAML. В стан­дар­тной биб­лиоте­ке Go най­дут­ся пар­серы для JSON и XML, а вот для YAML при­дет­ся подоб­рать что‑то сто­рон­нее — на выбор есть куча реали­заций. В этой статье мы сос­редото­чим­ся на таких пар­серах:

  • encoding/json, вер­сия go1.24.1;
  • encoding/xml, вер­сия go1.24.1;
  • yaml.v3, вер­сия 3.0.1, самая популяр­ная сто­рон­няя биб­лиоте­ка YAML для Go.

В наших при­мерах будем исполь­зовать JSON, но у всех трех пар­серов оди­нако­вые API, так что раз­ницы поч­ти никакой.

В сво­ей осно­ве эти пар­серы выпол­няют две глав­ные фун­кции:

  • Marshal (сери­али­зация) — прев­раща­ет струк­туры Go в стро­ки задан­ного фор­мата;
  • Unmarshal (десери­али­зация) — кон­верти­рует стро­ки фор­мата обратно в струк­туры Go.
Парсинг JSON в Go: как сделать это правильно?
Пар­синг JSON в 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.

Парсинг JSON в Go с помощью NewDecoder
Пар­синг JSON в Go с помощью NewDecoder
 

Атака 1: подмена данных при (де)сериализации

Иног­да нуж­но огра­ничить, какие имен­но поля струк­туры могут быть сери­али­зова­ны или десери­али­зова­ны.

Да­вай раз­берем прос­той при­мер: есть бэкенд‑сер­вер, на котором нас­тро­ен HTTP-хен­длер для соз­дания поль­зовате­лей и еще один — для их извле­чения пос­ле аутен­тифика­ции.

Ког­да соз­даешь поль­зовате­ля, воз­можно, тебе не захочет­ся, что­бы он мог задать поле IsAdmin сам (то есть пар­сить это поле из поль­зователь­ско­го вво­да).

Взаимодействие с бэкенд-сервером, где пользователь может изменить поле IsAdmin в структуре User, — такую возможность давать нельзя
Вза­имо­дей­ствие с бэкенд‑сер­вером, где поль­зователь может изме­нить поле IsAdmin в струк­туре User, — такую воз­можность давать нель­зя

Точ­но так же при получе­нии дан­ных поль­зовате­ля тебе может не понадо­бить­ся воз­вра­щать его пароль или дру­гие сек­ретные зна­чения.

Взаимодействие с сервером, где пользователь каким-то образом получает доступ к полю Password в структуре User, хотя это должно быть невозможно
Вза­имо­дей­ствие с сер­вером, где поль­зователь каким‑то обра­зом получа­ет дос­туп к полю Password в струк­туре User, хотя это дол­жно быть невоз­можно

Как мож­но ука­зать пар­серам не сери­али­зовать или десери­али­зовать поле?

 

Поля без меток

Да­вай сна­чала пос­мотрим, что про­изой­дет, если ты не уста­новишь тег 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-пра­вило. Оно не попало в наш ка­талог пра­вил на Semgrep registry, так как в зависи­мос­ти от тво­его кода велика веро­ятность получить кучу лож­ных сра­баты­ваний.

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 отоб­ражалось как - (исправ­лено в #3658);
  • в langchaingo поле MaxTokens так­же отоб­ражалось как - (исправ­лено в #1163).

Это поведе­ние чре­вато ошиб­ками и не при­носит осо­бой поль­зы (ну кро­ме воз­можнос­ти наз­вать поле -), и тем не менее оно опи­сано в до­кумен­тации пакета JSON.

info

Осо­бый слу­чай: если у тега поля сто­ит -, то это поле всег­да про­пус­кает­ся. Фокус в том, что поле с име­нем - все еще мож­но соз­дать, исполь­зуя тег -,.

Пар­серы для XML и YAML работа­ют похоже, но есть один под­вох: XML-пар­сер счи­тает тег <-> некор­рек­тным. Что­бы это испра­вить, нуж­но добавить для сим­вола «минус» прос­транс­тво имен. Нап­ример, прев­ратить его в <A:->.

Распаковываем поле с тегом - в форматах JSON, XML и YAML
Рас­паковы­ваем поле с тегом - в фор­матах JSON, XML и YAML

Хо­рошо, давай на этот раз все сде­лаем пра­виль­но.

type User struct {
// Имя пользователя в JSON, если не пустое
Username string `json:"username,omitempty"`
// Пароль в JSON, если не пустой
Password string `json:"password,omitempty"`
// Не отображать признак админа в JSON
IsAdmin bool `json:"-"`
}

На­конец‑то! Теперь нет никакой воз­можнос­ти для десери­али­зации поля IsAdmin.

Ты, навер­ное, спро­сишь: как же эти неп­равиль­ные нас­трой­ки могут прев­ратить­ся в уяз­вимос­ти в безопас­ности? Самый баналь­ный спо­соб — это, как в нашем при­мере, исполь­зовать -,... как JSON-тег для поля типа IsAdmin, которое поль­зователь ни в коем слу­чае не дол­жен кон­тро­лиро­вать.

Де­тек­тить такую шту­ку обыч­ными юнит‑тес­тами очень слож­но, ведь без спе­циаль­ного тес­та, который рас­паковы­вает дан­ные с клю­чом - и про­веря­ет, изме­нилось ли поле, ты такой баг не пой­маешь. Тут нуж­ны либо какие‑то прод­винутые фичи IDE, либо сто­рон­ний инс­тру­мент.

Взаимодействие с сервером, где пользователь может установить поле IsAdmin с помощью поля JSON
Вза­имо­дей­ствие с сер­вером, где поль­зователь может уста­новить поле IsAdmin с помощью поля JSON

Мы раз­работа­ли пуб­личное пра­вило для Semgrep, которое поможет тебе выявить подоб­ные проб­лемы в коде. Зацени его в деле:

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 (залата­но в пул‑рек­весте #33663).
  • По­доб­ная исто­рия у Kustomize: у него зас­ветилось поле Replacements в струк­туре plugin, тоже с omitempty (поп­равле­но в #5877).
  • Btcd тоже от­личил­ся, выдав поле MaxFeeRate в струк­туре TestMempoolAcceptCmd с тем самым omitempty.
  • Evcc пос­ледовал той же дорож­кой, по­казав поле Message в струк­туре Measurements с клю­чом omitempty.

Как видишь, раз­работ­чики час­то хотят уста­новить тег в зна­чение json:",omitempty", что­бы сох­ранить имя по умол­чанию и добавить опцию omitempty.

В отли­чие от пре­дыду­щего при­мера, этот вряд ли пов­лияет на безопас­ность и дол­жен лег­ко выяв­лять­ся в тес­тах. Любая попыт­ка сери­али­зовать или десери­али­зовать ввод с ожи­даемым име­нем поля про­валит­ся. Одна­ко, как ни стран­но, такое все еще встре­чает­ся даже в популяр­ных опен­сор­сных репози­тори­ях. Мы сде­лали пуб­личное пра­вило для Semgrep, что­бы помочь тебе находить подоб­ные баги в тво­их про­ектах. Исполь­зовать так:

semgrep -c r/trailofbits.go.unmarshal-tag-is-omitempty
 

Атака 2: разные парсеры — разные результаты

Что про­изой­дет, если разоб­рать одни и те же дан­ные с помощью раз­ных JSON-пар­серов и они выдадут раз­ные резуль­таты? И самое инте­рес­ное — какие осо­бен­ности пар­серов в Go дают зло­умыш­ленни­кам воз­можность ста­биль­но про­воци­ровать такие рас­хожде­ния?

Возь­мем для при­мера при­ложе­ние, пос­тро­енное на мик­росер­висной архи­тек­туре. В его сос­таве:

  • прок­си‑сер­вис, который обра­баты­вает все зап­росы поль­зовате­лей;
  • сер­вис авто­риза­ции, на который прок­си‑сер­вис ссы­лает­ся, что­бы про­верить, есть ли у поль­зовате­ля нуж­ные пра­ва для выпол­нения его зап­роса;
  • на­бор сер­висов биз­нес‑логики, которые прок­си‑сер­вис дер­гает, что­бы реали­зовать биз­нес‑логику.

В этом сце­нарии обыч­ный поль­зователь без прав адми­на пыта­ется выпол­нить UserAction — дей­ствие, которое ему раз­решено выпол­нять.

Пользователь успешно проходит аутентификацию
Поль­зователь успешно про­ходит аутен­тифика­цию

Во вто­ром сце­нарии тот же обыч­ный поль­зователь пыта­ется выпол­нить AdminAction — дей­ствие, которое ему ка­тего­ричес­ки зап­рещено.

Пользователь пытается войти, но аутентификация проваливается
Поль­зователь пыта­ется вой­ти, но аутен­тифика­ция про­вали­вает­ся

И вот про­исхо­дит магия: сер­висы начина­ют спо­рить о том, что же ты все‑таки пыта­ешь­ся сде­лать.

Службы Proxy и Authorization не согласны в разборе пользовательского ввода, что создает уязвимость в потоке данных
Служ­бы Proxy и Authorization не сог­ласны в раз­боре поль­зователь­ско­го вво­да, что соз­дает уяз­вимость в потоке дан­ных

Сер­вис авто­риза­ции, который написан на дру­гом язы­ке прог­рамми­рова­ния или исполь­зует нес­тандар­тный пар­сер для Go, будет пар­сить UserAction и даст поль­зовате­лю пра­ва на выпол­нение опе­рации. А вот прок­си‑сер­вис, который исполь­зует стан­дар­тный пар­сер Go, раз­берет AdminAction и отпра­вит его не тому сер­вису. Оста­ется воп­рос: какие наг­рузки мы можем соз­дать, что­бы добить­ся такого поведе­ния?

Это доволь­но популяр­ная архи­тек­тура, которую нам доводи­лось встре­чать во вре­мя наших ауди­тов, и имен­но в ней мы обна­ружи­вали обход аутен­тифика­ции. Проб­лемы, о которых мы рас­ска­жем ниже, дела­ют это воз­можным. Есть и дру­гие при­меры, но боль­шинс­тво из них сле­дуют той же модели: ком­понент, отве­чающий за про­вер­ку безопас­ности, и ком­понент, осу­щест­вля­ющий дей­ствия, по‑раз­ному видят вход­ные дан­ные. Вот нес­коль­ко таких при­меров в раз­личных сце­нари­ях:

 

Дубли полей

Пер­вая уяз­вимость, которую мы раз­берем, — это дуб­лирова­ние клю­чей. Что будет, если во вход­ном JSON один и тот же ключ встре­чает­ся дваж­ды? А тут уже все зависит от пар­сера!

В Go пар­сер JSON всег­да возь­мет пос­ледний эле­мент. И такую логику никак не поменять.

_ = json.Unmarshal([]byte(`{
"action": "Action1",
"action": "Action2"
}`), &a)
// Итог: ActionRequest{Action:"Action2"} последний ключ побеждает!

Это стан­дар­тное поведе­ние боль­шинс­тва пар­серов. Но, как по­каза­ли ребята из Bishop Fox, 7 из 49 про­тес­тирован­ных пар­серов выбира­ют пер­вый ключ:

  • Go: jsonparser, gojay;
  • C++: rapidjson;
  • Java: json-iterator;
  • Elixir: Jason, Poison;
  • Erlang: jsone.

Ни один из них не явля­ется самым популяр­ным JSON-пар­сером для сво­его язы­ка, хотя некото­рые из них весь­ма рас­простра­нен­ные.

Итак, если наш 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"}

Это про­тиво­речит докумен­тации, где ска­зано:

Что­бы рас­парсить JSON в струк­туру, метод Unmarshal сопос­тавля­ет клю­чи вхо­дяще­го объ­екта с клю­чами, которые исполь­зует Marshal (либо имя поля струк­туры, либо его тег), пред­почитая точ­ное сов­падение, но так­же при­нимая сов­падение без уче­та регис­тра.

Ты можешь даже исполь­зовать сим­волы Unicode! В при­мере ниже мы исполь­зуем сим­вол ſ (латин­ское дол­гое s) как s и K (знак Кель­вина) как k. В нашем тес­тирова­нии биб­лиоте­ки JSON, которая выпол­няет срав­нение, толь­ко эти два сим­вола Unicode соот­ветс­тву­ют ASCII-сим­волам.

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 и всех осталь­ных, которые мы тес­тирова­ли. В резуль­тате обра­зова­лась куча серь­езных уяз­вимос­тей, вклю­чая те, что нам уда­лось вскрыть в ходе ауди­тов.

И пос­ледний штрих: отклю­чить это поведе­ние нель­зя, нес­мотря на то что поль­зовате­ли жалу­ются на ды­рявую безопас­ность уже с 2016 года.

Это каса­ется толь­ко пар­сера JSON. Пар­серы для XML и YAML исполь­зуют точ­ные сов­падения.

Изображение, обобщающее работу всех трех парсеров
Изоб­ражение, обоб­щающее работу всех трех пар­серов

Ес­ли тебя инте­ресу­ют раз­личия в обра­бот­ке JSON в раз­ных пар­серах, рекомен­дуем про­читать эти два пос­та:

 

Атака 3: путаница в форматах данных

Для финаль­ной ата­ки давай пос­мотрим, что будет, если рас­парсить JSON-файл XML-пар­сером или исполь­зовать какой‑нибудь дру­гой непод­ходящий фор­мат.

Возь­мем для при­мера CVE-2020-16250: бай­пас защиты HashiCorp Vault в методе аутен­тифика­ции через AWS IAM. Эту уяз­вимость обна­ружи­ла коман­да Google Project Zero (если инте­рес­но пог­рузить­ся в детали, в бло­ге есть пост под наз­вани­ем Enter the Vault: Authentication Issues in HashiCorp Vault). Мы не будем вывали­вать здесь все тон­кости, но в целом вот как выг­лядит стан­дар­тный про­цесс аутен­тифика­ции HashiCorp Vault через AWS IAM:

  1. AWS-ресурс, ска­жем фун­кция AWS Lambda, под­писыва­ет зап­рос GetCallerIdentity.
  2. Этот зап­рос отправ­ляет­ся на сер­вер Vault.
  3. Сер­вер Vault собира­ет зап­рос и пересы­лает его в AWS Security Token Service (STS).
  4. AWS STS про­веря­ет под­пись.
  5. Ес­ли все окей, AWS STS воз­вра­щает XML-документ с дан­ными о роли.
  6. Сер­вер Vault пар­сит XML, извле­кает иден­тифика­тор и, если у этой роли есть дос­туп к зап­рашива­емым сек­ретам, отправ­ляет их обратно.
  7. Те­перь AWS-ресурс может исполь­зовать сек­реты, нап­ример что­бы авто­ризо­вать­ся в базе дан­ных.
Процесс аутентификации с помощью Vault
Про­цесс аутен­тифика­ции с помощью Vault

Ко­ман­да Google Project Zero обна­ружи­ла, что в шаге 2 зло­умыш­ленник может получить слиш­ком боль­шой кон­троль, вклю­чая задание всех заголов­ков зап­роса, которые Vault фор­миру­ет на шаге 3. Осо­бен­но кри­тич­но, что, уста­новив заголо­вок Accept как application/json, AWS STS в шаге 5 вмес­то ожи­даемо­го XML-докумен­та воз­вра­щает JSON.

В ито­ге сер­вер Vault начина­ет пар­сить этот JSON с исполь­зовани­ем XML-пар­сера на Go. А пос­коль­ку XML-пар­сер весь­ма снис­ходите­лен и умуд­ряет­ся разоб­рать поч­ти все, что хоть отда­лен­но сма­хива­ет на XML, этот хаос из JSON ста­новит­ся дос­таточ­ным для обхо­да аутен­тифика­ции, если есть воз­можность хотя бы час­тично управлять JSON-отве­том.

Процесс аутентификации Vault и уязвимость
Про­цесс аутен­тифика­ции Vault и уяз­вимость

Да­вай раз­берем три хит­рости, поз­воля­ющие пар­сить фай­лы некор­рек­тным пар­сером Go, и соз­дадим файл‑полиг­лот, который мож­но скор­мить пар­серам JSON, XML и YAML. Каж­дому пар­серу он выдаст свой уни­каль­ный резуль­тат.

 

Неизвестные ключи

По умол­чанию пар­серы для JSON, XML и YAML не бло­киру­ют неиз­вес­тные поля — то есть свой­ства во вхо­дящих дан­ных, которые не соот­ветс­тву­ют ни одно­му полю в целевой струк­туре.

Поведение парсеров JSON, XML и YAML при обработке неизвестных ключей
По­веде­ние пар­серов JSON, XML и YAML при обра­бот­ке неиз­вес­тных клю­чей
 

Мусорные данные в начале

Из трех пар­серов толь­ко XML-пар­сер перева­рива­ет мусор­ные дан­ные в начале.

Поведение парсеров JSON, XML и YAML при наличии мусора в начале данных
По­веде­ние пар­серов JSON, XML и YAML при наличии мусора в начале дан­ных
 

Мусорные данные в конце

Толь­ко XML-пар­сер может прог­лотить про­изволь­ный мусор в кон­це дан­ных.

Поведение парсеров JSON, XML и YAML при столкновении с мусорными данными в конце файла
По­веде­ние пар­серов JSON, XML и YAML при стол­кно­вении с мусор­ными дан­ными в кон­це фай­ла

Ис­клю­чение сос­тавля­ет исполь­зование пар­сера Decoder API с потоко­выми дан­ными — в этом слу­чае JSON-пар­сер при­мет мусор­ные дан­ные в кон­це. Это от­кры­тая проб­лема, для которой пока нет зап­ланиро­ван­ного решения.

Поведение парсеров JSON, XML и YAML при обработке мусора в конце данных с использованием API Decoder
По­веде­ние пар­серов JSON, XML и YAML при обра­бот­ке мусора в кон­це дан­ных с исполь­зовани­ем API Decoder
 

Создаем полиглот

Как объ­еди­нить все рас­смот­ренные нами спо­собы поведе­ния, что­бы соз­дать файл‑полиг­лот, который:

  • мож­но обра­ботать пар­серами JSON, XML и YAML на Go;
  • воз­вра­щает раз­ные резуль­таты для каж­дого пар­сера?

При­годит­ся знать, что JSON — это под­мно­жес­тво YAML.

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.

К сожале­нию, JSON дает толь­ко один спо­соб сде­лать пар­синг более стро­гим: DisallowUnknownFields. Эта фича зап­реща­ет неиз­вес­тные поля во вхо­дящем JSON. YAML пред­лага­ет ана­логич­ную фун­кцию KnownFields(true). А вот для XML была попыт­ка сде­лать неч­то подоб­ное, но пред­ложение завер­нули.

Что­бы покон­чить с оставши­мися косяка­ми стан­дар­тных нас­тро­ек безопас­ности, нам при­дет­ся сочинить что‑то свое, кас­томное и нем­ного хакер­ское. Взгля­ни на сле­дующий блок кода с фун­кци­ей strictJSONParse. Это наша попыт­ка сде­лать раз­бор JSON стро­же, хотя тут есть свои огра­ниче­ния:

  1. Пло­хая про­изво­дитель­ность: JSON при­ходит­ся пар­сить дваж­ды, что замет­но замед­ляет про­цесс.
  2. Не­пол­ная детек­ция: в некото­рых край­них слу­чаях недоче­ты все‑таки оста­ются, как ука­зано в ком­мента­риях к фун­кции.
  3. Низ­кий потен­циал внед­рения: эти меры безопас­ности не встро­ены в биб­лиоте­ки как защищен­ные нас­трой­ки по умол­чанию или нас­тра­иваемые опции, так что мас­совое рас­простра­нение вряд ли получит­ся.

Но если ты вдруг обна­ружишь уяз­вимость в сво­ем коде, то даже такое несовер­шенное решение может помочь затыкать дыру, пока ты работа­ешь над более надеж­ным вари­антом.

// 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

Что­бы фича ста­ла мас­совой и дей­стви­тель­но решила проб­лему на гло­баль­ном уров­не, ее нуж­но вшить в биб­лиоте­ку и вклю­чить по умол­чанию. Вот тут‑то и всту­пает в игру JSON v2 — новая вер­сия биб­лиоте­ки пар­синга JSON для Go. Пока это толь­ко пред­ложение, но огромная работа уже про­дела­на, и мы наде­емся, что сов­сем ско­ро этот стан­дарт вый­дет. JSON v2 пре­вос­ходит пер­вую вер­сию по мно­гим парамет­рам:

  • Зап­рещены дуб­лиру­ющиеся име­на: «...в вер­сии v2 JSON-объ­ект с дуб­лиру­ющи­мися име­нами при­водит к ошиб­ке. Поведе­ние регули­рует­ся опци­ей jsontext.AllowDuplicateNames».
  • Учи­тыва­ется регистр при сов­падении: «...в v2 поля сов­пада­ют точ­но, с уче­том регис­тра. Опции MatchCaseInsensitiveNames и jsonv1.MatchCaseSensitiveDelimiter кон­тро­лиру­ют это поведе­ние».
  • Есть опция RejectUnknownMembers, хотя она не вклю­чена по умол­чанию (ана­логич­на DisallowUnknownFields).
  • Есть воз­можность обра­баты­вать дан­ные из io.Reader с помощью фун­кции UnmarshalRead, про­веряя наличие EOF и не допус­кая лиш­них дан­ных в кон­це.

Хо­тя это пред­ложение реша­ет мно­гие обсужда­емые здесь проб­лемы, труд­ности в эко­сис­теме Go сох­ранят­ся, пока нов­шес­тво не наберет популяр­ность. Спер­ва нуж­но офи­циаль­ное одоб­рение, пос­ле чего раз­работ­чикам при­дет­ся внед­рять это решение в весь сущес­тву­ющий код на Go, пар­сящий JSON. Пока это­го не слу­чит­ся, уяз­вимос­ти про­дол­жат соз­давать рис­ки.

 

Памятка для разработчика

  1. Вклю­чи стро­гий пар­синг по умол­чанию. Для JSON исполь­зуй DisallowUnknownFields, для YAML — KnownFields(true). Увы, это все, что реаль­но мож­но сде­лать с API пар­сера нап­рямую в Go.
  2. Сох­раняй кон­систен­тность на гра­ницах. Ког­да дан­ные про­ходят через нес­коль­ко сер­висов, убе­дись, что пар­синг работа­ет пос­ледова­тель­но, — исполь­зуй оди­нако­вый пар­сер или добавь допол­нитель­ные уров­ни валида­ции, нап­ример такую шту­ку, как strictJSONParse.
  3. Сле­ди за прог­рессом JSON v2. Пог­лядывай за раз­работ­кой биб­лиоте­ки JSON v2 для Go, которая реша­ет мас­су проб­лем, пред­лагая более безопас­ные дефол­ты.
  4. Ис­поль­зуй ста­тичес­кий ана­лиз. Вру­бай пра­вила Semgrep, что­бы выловить уяз­вимые пат­терны в сво­ем коде, осо­бен­но некор­рек­тное исполь­зование тега - и полей omitempty. Поп­робуй запус­тить соз­данные нами пра­вила.
 

Выводы

Мы пред­ложили спо­собы смяг­чить пос­ледс­твия и методы обна­руже­ния, но в дол­госроч­ной пер­спек­тиве все рав­но при­дет­ся менять под­ход к работе пар­серов. Пока биб­лиоте­ки пар­серов не выберут безопас­ность по умол­чанию, раз­работ­чикам нуж­но дер­жать ухо вос­тро.

  • Подпишись на наc в Telegram!

    Только важные новости и лучшие статьи

    Подписаться

  • Подписаться
    Уведомить о
    0 комментариев
    Межтекстовые Отзывы
    Посмотреть все комментарии