Что тут у нас сегодня? Снова мой любимый класс уязвимостей: атаки на парсер! 🦄 Встречаем: CVE-2026-27136 — уязвимость класса мутационной XSS (mXSS) в HTML-парсере пакета `golang.org/x/net/html`(версии до v0.55.0).
Суть уязвимости проста: токенизатор не отбрасывал дублирующиеся атрибуты при парсинге HTML вопреки спецификации WHATWG (раздел «13.2.5.33 Attribute name state»), которая требует учитывать только первое вхождение.
⌨️ Сценарий атаки
Допустим, в приложении есть вот такой код:
func sanitizeHTML(input string) string {
doc, _ := html.Parse(strings.NewReader(input))
// Обходим дерево, удаляем опасные атрибуты
removeDangerousAttrs(doc) // удаляет onerror, onclick и т.д.
var buf strings.Builder
html.Render(&buf, doc)
return buf.String()
}Злоумышленник отправляет следующий HTML:
<img src=x onerror= onerror=alert(1)>
И дальше происходит следующее:
1️⃣ Парсинг: токенизатор сохраняет оба атрибута
onerror:•
onerror=""•
onerror="alert(1)"2️⃣ Санитизация: функция
removeDangerousAttrs находит первый onerror и удаляет его. Но дубликат onerror="alert(1)" остаётся в дереве — санитайзер, ожидающий, что дерево нормализовано и соответствует спеке, обрабатывает только первое вхождение.3️⃣ Рендеринг:
html.Render() сериализует дерево и выводит:<img src="x" onerror="alert(1)"/>
🩹 Уязвимость и исправление
Уязвимость находилась в файле
html/token.go, функция readTag() и заключалась в том, что проверка if saveAttr && key_non_empty выполнялась без проверки на уникальность имени атрибута. Любой дублирующийся атрибут попадал в срез z.attr.Исправление уязвимости можно посмотреть в коммите a452f3c. Ключевое изменение — в логике сохранения атрибута:
- // Было: сохраняем любой непустой атрибут
- if saveAttr && z.pendingAttr[0].start != z.pendingAttr[0].end {
- z.attr = append(z.attr, z.pendingAttr)
- }
+ // Стало: извлекаем ключ, проверяем на дубликат
+ key := strings.ToLower(string(z.buf[z.pendingAttr[0].start:z.pendingAttr[0].end]))
+ if saveAttr && z.pendingAttr[0].start != z.pendingAttr[0].end && !z.attrNames[key] {
+ z.attr = append(z.attr, z.pendingAttr)
+ z.attrNames[key] = true // регистрируем имя
+ }
😻 Пара мыслей на тему
1️⃣ Должен ли санитизатор, работающий с нормализованными данными, имеющими спецификацию, допускать, что они не будут ей соответствовать? Разработчики скажут, конечно нет, ведь нормализация — это жесткий контракт, и какой в нём смысл, если на него не полагаться? Аппсеки возразят, что тогда подобные «правильные» санитизаторы и впредь будут отваливаться пачками при подобных уязвимостях, и, в целом, ничего не мешает, предусмотреть в них подобные ситуации.
Я придерживаюсь мнения, что если контракт есть, то санитизатор должен на него полагаться в части своей основной функциональности. Но только в том случае, если выполнение контракта подтверждено, т.е. ранее была осуществлена валидация соответствия данных спецификации. В противном случае, санитизатор должен брать на себя ответственность за эту валидацию и возвращать ошибку (с записью в лог), если валидация провалилась.
2️⃣ Те, кто внимательно изучил код патча, могли заметить
strings.ToLower при формирования ключа. Этим разработчики, с одной стороны — выполнили также требования того же раздела спеки о регистронезависимости имен атрибутов, а с другой — избежали байпаса их патча кейсами типа:<img src="x" onError="alert(1)" onerror="alert(1)"/>
3️⃣ Ну, а те, кто изучил код патча СОВСЕМ внимательно, могли задаться вопросом, а не вносит ли он уязвимость к DoS через колизию хэшей во вновь добавленной мапе
attrNames?И да, и нет. Нет — потому что в Go мапы используют рандомизацию хэшей (пруф) и этой атаке не подвержены. Да — потому что в другом месте
net/html, имена атрибутов различных тегов сравнивались по алгоритму со сложностью O(k × m²) от числа тегов и атрибутов. Эта проблема была исправлена в том же релизе, в 08be507Всё! 🤗
#уязвимости #аппсек