Когда компилятор врёт: нарушение memory safety в safe-коде Go
Два бага компилятора Go (CVE-2026-27143 и CVE-2026-27144) позволяли получить control-flow hijack и memory corruption через обычный safe-код — без unsafe, CGO и data races. Разбор механизма и исправление в Go 1.26.1.
Если вы пишете на Go и считаете, что safe-код автоматически защищает от memory corruption — вот два реальных CVE, которые доказывают обратное: баги компилятора Go (исправлены в 1.26.1) позволяли получить control-flow hijack и повреждение памяти без единого unsafe, CGO, ассемблерной вставки и data race.
Исследователь Доминик Цёлек (ciolek.dev) обнаружил два бага в цепочке компиляции Go и сообщил о них команде безопасности. Та ответила на первый отчёт через 3 минуты, на второй — через 4. Что показательно: git blame указал на самого исследователя как автора кода, в котором обнаружился баг, — он же сам написал его три года назад.
Ключевые выводы
Два бага компилятора Go нарушали memory safety без unsafe
CVE-2026-27143 и CVE-2026-27144, исправлены в Go 1.26.1
- CVE-2026-27143: переполнение
int8-счётчика в цикле → prove pass удалял bounds check → инъекция инструкций - CVE-2026-27144: no-op-конверсия
*p = T(*q)вместо*p = *q→ компилятор пропускал careful copy path при пересечении памяти → memory corruption - Общая причина: «counterfeit certainty» — оптимизатор повышал «вероятно безопасно» до «доказано безопасно» слишком рано
- Memory safety — свойство всей цепочки инструментов (frontend, optimizer, lowering, runtime, codegen), а не только языка
- «Every optimization is a security claim» — каждая оптимизация компилятора является утверждением о безопасности
Баг 1 (CVE-2026-27143): переполнение счётчика цикла убирает bounds check
Первый баг связан с фазой prove pass — проходом компилятора, который статически доказывает инварианты и на основании этого удаляет избыточные проверки. Идея хорошая: зачем проверять границы массива, если компилятор «знает», что индекс всегда находится в допустимом диапазоне?
Рассмотрим цикл с переменной типа int8, шагом 10 и лимитом 120:
На первый взгляд код безопасен: i не превышает 120, массив размером 200 байт — никакого выхода за границы. Prove pass рассуждал именно так: раз условие цикла гарантирует i ∈ [0, 120], bounds check можно удалить.
Но int8 хранит значения от -128 до 127. После 120 следующий шаг — 130, что переполняет тип и оборачивается в -126. Условие i < 120 снова становится истинным, цикл продолжается, но уже с отрицательным индексом. Bounds check, который мог бы это поймать, компилятор уже выбросил — ведь он «доказал» безопасность. В результате происходит запись за пределы массива, что открывает возможность для control-flow hijack и инъекции инструкций.
Prove pass повысил «индекс вероятно в пределах диапазона» до «индекс доказано в пределах диапазона», не учтя возможность целочисленного переполнения малых типов.
Баг 2 (CVE-2026-27144): no-op-конверсия и пересечение памяти
Второй баг тоньше. Go использует «careful copy path» — специальный путь копирования данных при работе с пересекающимися областями памяти (overlapping memory). Без него можно записать в destination данные, которые уже были перезаписаны в процессе копирования из source.
Проблема возникала при казалось бы безобидной конструкции — no-op-конверсии типа при присваивании через указатель:
Конверсия T(*q) здесь не делает ничего содержательного — типы совпадают. Компилятор распознал её как no-op и оптимизировал… но при этом забыл переключиться на careful copy path. Если области памяти p и q пересекаются — данные копируются некорректно, что приводит к повреждению памяти.
Особенно опасным сценарием оказались slice values: заголовок слайса содержит указатель на данные, длину и ёмкость. При некорректном копировании заголовка указатель мог указывать на произвольную память, а длина — быть некорректной.
Общая причина: «counterfeit certainty»
Оба бага объединяет одна концептуальная проблема: компилятор принимал «вероятно безопасно» за «доказано безопасно» и на этом основании удалял защитные механизмы. Цёлек называет это counterfeit certainty — поддельная уверенность.
В первом случае prove pass «доказал» безопасность индекса, не учтя переполнение малых целых типов. Во втором случае оптимизатор «знал», что конверсия — no-op, и сделал из этого вывод, что careful copy path не нужен. В обоих случаях рассуждение было формально правильным, но неполным: не были рассмотрены все возможные состояния программы.
Every optimization is a security claim. — Каждая оптимизация компилятора является утверждением о безопасности.
Когда компилятор удаляет bounds check — он утверждает, что выход за границы невозможен. Когда он пропускает careful copy path — он утверждает, что пересечения памяти нет. Ошибка в этих утверждениях напрямую превращается в уязвимость безопасности.
Memory safety — свойство всей цепочки инструментов
Принято считать, что memory-safe языки (Go, Rust, Java, C#) гарантируют отсутствие memory corruption по определению. Это правда — в рамках спецификации языка. Но между спецификацией и работающей программой стоит компилятор.
Цепочка компиляции Go включает несколько фаз, каждая из которых делает предположения о корректности предыдущей:
- Frontend (парсинг, type checking) — проверяет синтаксис и типы
- Optimizer (prove pass, escape analysis) — доказывает инварианты и устраняет лишние проверки
- Lowering — преобразует высокоуровневые операции в машинно-зависимые
- Runtime (garbage collector, goroutine scheduler) — управляет памятью во время выполнения
- Codegen — генерирует итоговый машинный код
CVE-2026-27143 затронул фазу optimizer, CVE-2026-27144 — фазу lowering. Обе уязвимости существовали несмотря на то, что исходный код был написан на «memory-safe» языке. Memory safety — это свойство, которое нужно обеспечивать на каждом уровне стека, не только на уровне языка.
Go security team: ответ за 3–4 минуты
Исследователь отправил отчёты об обоих багах в Go security team. Первый ответ пришёл через 3 минуты, второй — через 4. Оба бага были исправлены в Go 1.26.1. Сам Цёлек отметил, что процесс взаимодействия с командой был образцовым: быстрая реакция, прозрачная коммуникация и оперативный выпуск патча.
При разборе бага исследователь запустил git blame на подозрительный участок кода компилятора — и обнаружил собственное имя. Три года назад он сам написал этот код. По его словам, это добавило исследованию особой иронии и заставило более внимательно относиться к тому, что «очевидные» оптимизации несут в себе скрытые предположения о безопасности.
FAQ
Меня касаются эти уязвимости, если я пишу обычный Go-код без unsafe?
Да, потенциально. Оба бага эксплуатируются через обычный safe-код. CVE-2026-27143 требует цикла с малым целым типом (int8/int16) и определённого шага итерации. CVE-2026-27144 — no-op-конверсии типа при присваивании через указатель в сценариях с overlapping memory. Обновитесь до Go 1.26.1 или новее.
Как prove pass удаляет bounds check и почему это опасно?
Prove pass — фаза оптимизатора Go, которая статически доказывает факты о значениях переменных. Если он «доказал», что индекс всегда находится в допустимом диапазоне, то bounds check (проверка границ массива) признаётся избыточной и удаляется. Опасность в том, что если доказательство было построено на неполных предположениях (например, не учло переполнение), работа без bounds check открывает возможность для записи за пределы памяти.
Rust тоже может быть уязвим через баги компилятора?
Да. Rust Compiler (rustc) тоже имел баги, приводившие к undefined behavior в safe-коде — они фиксировались как «soundness issues». Ни один компилятор не застрахован от ошибок в оптимизаторе или кодогенераторе. Именно поэтому важный вывод статьи: memory safety — свойство всей цепочки инструментов, а не только языка.
Что такое careful copy path и зачем он нужен?
При копировании данных между пересекающимися областями памяти (overlapping memory) важен порядок операций: если source и destination перекрываются, стандартное побайтовое копирование слева направо может перезаписать ещё не скопированные данные. Careful copy path учитывает направление копирования и пересечение — аналог memmove против memcpy в C.
Как быстро нужно обновить Go после выхода патча безопасности?
Немедленно, если в вашем коде есть циклы с int8/int16-счётчиками или конверсии типов при копировании через указатели. В общем случае — при первой возможности. Go поддерживает только две последние минорные версии; исправления безопасности не бэкпортируются в старые версии.
Выводы
История с CVE-2026-27143 и CVE-2026-27144 показывает: использование memory-safe языка — необходимое, но недостаточное условие для безопасности программы. Компилятор — не чёрный ящик с гарантиями, а сложная система с собственными инвариантами, которые могут нарушаться.
Оба бага существовали в «тихих» местах: оптимизаторе, который молча удалял проверки, и в lowering-фазе, которая молча пропускала защитный путь копирования. Ни один линтер, ни один статический анализатор не мог их обнаружить — ведь исходный код был совершенно корректным с точки зрения языка.
Практический вывод прост: обновляйтесь. Следите за golang-announce, обновляйтесь до новых версий Go сразу после выхода патчей безопасности, и помните, что «написано на Go» ≠ «memory-safe по определению».