Когда компилятор врёт: нарушение 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.

Обложка: Когда компилятор врёт: нарушение memory safety в safe-коде Go

Если вы пишете на 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:

			var arr [200]byte
for i := int8(0); i < 120; i += 10 {
    arr[i] = 1  // bounds check должен быть здесь
}
		

На первый взгляд код безопасен: 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-конверсии типа при присваивании через указатель:

			// Безопасно — компилятор использует careful copy path:
*p = *q

// Казалось бы, то же самое — но баг:
*p = T(*q)  // no-op conversion, T совпадает с типом *q
		

Конверсия 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
1
Меня касаются эти уязвимости, если я пишу обычный Go-код без unsafe?

Да, потенциально. Оба бага эксплуатируются через обычный safe-код. CVE-2026-27143 требует цикла с малым целым типом (int8/int16) и определённого шага итерации. CVE-2026-27144 — no-op-конверсии типа при присваивании через указатель в сценариях с overlapping memory. Обновитесь до Go 1.26.1 или новее.

2
Как prove pass удаляет bounds check и почему это опасно?

Prove pass — фаза оптимизатора Go, которая статически доказывает факты о значениях переменных. Если он «доказал», что индекс всегда находится в допустимом диапазоне, то bounds check (проверка границ массива) признаётся избыточной и удаляется. Опасность в том, что если доказательство было построено на неполных предположениях (например, не учло переполнение), работа без bounds check открывает возможность для записи за пределы памяти.

3
Rust тоже может быть уязвим через баги компилятора?

Да. Rust Compiler (rustc) тоже имел баги, приводившие к undefined behavior в safe-коде — они фиксировались как «soundness issues». Ни один компилятор не застрахован от ошибок в оптимизаторе или кодогенераторе. Именно поэтому важный вывод статьи: memory safety — свойство всей цепочки инструментов, а не только языка.

4
Что такое careful copy path и зачем он нужен?

При копировании данных между пересекающимися областями памяти (overlapping memory) важен порядок операций: если source и destination перекрываются, стандартное побайтовое копирование слева направо может перезаписать ещё не скопированные данные. Careful copy path учитывает направление копирования и пересечение — аналог memmove против memcpy в C.

5
Как быстро нужно обновить Go после выхода патча безопасности?

Немедленно, если в вашем коде есть циклы с int8/int16-счётчиками или конверсии типов при копировании через указатели. В общем случае — при первой возможности. Go поддерживает только две последние минорные версии; исправления безопасности не бэкпортируются в старые версии.

Выводы

История с CVE-2026-27143 и CVE-2026-27144 показывает: использование memory-safe языка — необходимое, но недостаточное условие для безопасности программы. Компилятор — не чёрный ящик с гарантиями, а сложная система с собственными инвариантами, которые могут нарушаться.

Оба бага существовали в «тихих» местах: оптимизаторе, который молча удалял проверки, и в lowering-фазе, которая молча пропускала защитный путь копирования. Ни один линтер, ни один статический анализатор не мог их обнаружить — ведь исходный код был совершенно корректным с точки зрения языка.

Практический вывод прост: обновляйтесь. Следите за golang-announce, обновляйтесь до новых версий Go сразу после выхода патчей безопасности, и помните, что «написано на Go» ≠ «memory-safe по определению».