Разбираем механику уязвимости, при которой система сама декодирует входящее вложение и переполняет буфер ещё до того, как пользователь что-либо увидит. Нетривиальность здесь в двух вещах: ошибка памяти возникает не в приложении, которое получило файл, а в фоновом демоне с бо́льшими привилегиями, и запускается она без единого действия человека.
Почему отсутствие клика меняет всё
Современный телефон выполняет над входящим контентом массу работы автоматически, в момент его поступления. Вложение записывается на диск, индексируется для поиска, а часть индексации передаёт содержимое в фото-подсистему, которая каталогизирует медиа и генерирует миниатюры и производные изображения — чтобы фототека оставалась быстрой и доступной поиском.
Каждый из этих шагов — это парсеркод, разбирающий байты входного файла над байтами, которые контролирует атакующий, и работает он в демоне с бо́льшими привилегиями, чем у песочницыизолированное окружение, ограничивающее доступ приложения к системе приложения-получателя. Отсюда иерархия опасности, измеряемая в кликах: баг, требующий нажатия на ссылку, требует действий жертвы, а баг, не требующий от жертвы ничего, стоит на вершине.
Для zero-click нужны ровно две вещи: ошибка памяти в парсере и путь, достигающий этого парсера без участия человека. Разница между «пользователь открыл файл» и «система сама обработала вложение» формулируется прямо: при обычном открытии что-то делает пользователь, а здесь «You did not open anything. The machinery opened it for you.» Никто не открывал приложение, не нажимал вложение и даже не смотрел уведомление.
Как RGB на входе превращается в RGBA на выходе
Суть ошибки — channel-count confusionпутаница в числе цветовых каналов: код рассчитывает объём по одному числу каналов, а записывает по другому. На входе три канала, на выходе — четыре.
Аллокатор вычисляет размер буфера как произведение числа каналов на размер одного float:
size_t bytes_per_pixel = channels * sizeof(float); // channels == 3 -> 12То есть под RGB выделяется 12 байт на пиксель. Но фактическая запись выдаёт R, G, B и постоянный альфа-канал — четыре float, то есть 16 байт на пиксель. Четвёртый канал берётся не из файла, а из константы 0x3f800000 — это float 1.0. Поэтому на каждый пиксель записывается 16 байт в буфер, рассчитанный на 12, и переполнение составляет ровно 4 байта на пиксель. Оно накапливается построчно по всему изображению: при 448 на 448 пикселей это примерно 800 килобайт, записанных сразу за концом выделенного блока, в то, что аллокатор выдал следующим.
Из 16 записанных байт 12 — это значения красного, зелёного и синего, взятые напрямую из пиксельных данных файла и контролируемые атакующим. Фиксирован только четырёхбайтовый альфа-канал. Это чистая запись: она никогда не читает соседнюю память, только затирает её.
Как фаззер, не знавший про EXR, вышел на находку
Поиск вёлся через LLM-guided fuzzingфаззинг, в котором модели читают дизассемблирование цели и предлагают мутации, сохраняющие структурную валидность файла. Модели разбирали целевой листдизассемблированный лист — участок кода, который фаззер выбрал целью для разбора, описывали, какие поля код доверяет, и предлагали мутации, бьющие точно по счётчикам, размерам и тегам типов, на которые код ветвится.
Первым артефактом было не падение, а статическое свидетельство: модель, разбирая libAppleEXR.dylib, заметила несоответствие — аллокацию, размер которой вычислялся из числа каналов, и рядом цикл SIMD-записи, чей шаг не совпадал с этим числом каналов. Статическая асимметрия — только зацепка, пока что-то реально не упадёт во время выполнения. Но форма бага была узнаваема: «the oldest bug in graphics» — на входе RGB, на выходе RGBA.
Чтобы проверить гипотезу, был собран минимальный EXR 448 на 448 с тремя float-каналами и ZIP-сжатием и прогнан через harnessминимальную обвязку, которая подаёт файл в целевой код и запускает его обработку. Обвязка просто просит ImageIOсистемный фреймворк Apple, через который проходит декодирование изображений на платформе декодировать файл. Файл упал.
Что показал первый краш
Команда ./decode test_marker_448x448.exr вывела Segmentation fault: 11. Это не уязвимость, а приглашение: «A segfault is not a vulnerability. A segfault is an invitation. Time to read the wreckage.» Чтение отчёта о падении показало, что сбой произошёл именно в той функции, на которую указала модель, — с именем, которое могло понравиться только шаблону.
Faulting-инструкцией оказалась ARM NEONнабор векторных инструкций ARM для параллельной обработки нескольких значений за одну операцию-запись: она берёт два векторных регистра, переплетает их дорожки, пишет 32 байта по адресу в x17 и сдвигает x17 вперёд на 32 для следующего прохода. x17 — курсор записи, идущий по буферу назначения; цикл продвигает его по 32 байта, упаковывая четырёхканальные пиксели, прямо за конец выделения в 12 байт на пиксель и в следующую область кучи. Именно поэтому эта инструкция — точка перезаписи: она пишет четырёхканальные пиксели туда, где буфер рассчитан лишь на три канала, и каждый её проход выносит запись за границу выделенного блока.
Запись чистая: нет чтения соседней кучи, нет утечки, она только затирает соседа. Адрес сбоя в отчёте находился ровно на один байт за концом отображённой области — декодер действительно выделил буфер на три канала и действительно записал четыре. Сбой был привязан к CompressedInterleave4<unsigned int, 1, 1, 1, 0>+452.
Стенд, который не врёт
Harness удобен для механики, но врёт: он работает с неверными привилегиями, состоянием аллокатора и в неверном процессе. Важен именно баг, срабатывающий в реальной системе, поэтому стенд управлял настоящим приложением Photos через AppleScript — чтобы система принимала файлы так же, как настоящие.
Цикл перебирал варианты crafted-изображений, каждый импортировался в Photos, после чего собирался свежий crash-отчёт, помечался входным файлом, и цикл повторялся:
repeat with variant in craftedImages
tell application "Photos" to import (variant as POSIX file)
delay 2Параметры менялись между прогонами: разные размеры изображения толкали переполнение на разное расстояние, разные значения пикселей меняли записываемые байты, разные схемы предварительного выделения меняли то, что оказывалось рядом. На выходе читались байты, вылившиеся в соседний объект, и они отслеживали входные пиксели. Цикл «Craft, feed, observe, adjust» повторялся часами.
Каждая итерация уточняла форму дыры: как далеко достаёт заданный размер изображения, в какие области кучи можно попасть и насколько детерминировано попадание.
От «оно падает» к «я владею байтами»
«I own the bytes» означает, что три из каждых четырёх записываемых байт — значения, выбранные атакующим: 12 из 16 записанных байт это RGB прямо из пиксельных данных файла, контролируемые атакующим. Четвёртый байт каждой группы фиксирован — альфа-канал, константа 0x3f800000, которую изменить нельзя.
Чтобы получить такое свойство, нужна детерминированная посадка переполнения в заранее выбранный слот кучи: если выделить много объектов одного размера, освободить каждый второй, чтобы открыть предсказуемые промежутки, а затем вызвать аллокацию с переполнением, оно попадёт в слот, соседа которого вы разместили намеренно. Когда размер буфера-назначения зафиксирован размерами изображения, а сосед размещён вручную, попадание становится детерминированным: «Same slot, every single run».
Связь между входными пиксельными значениями и записываемыми байтами стабильна и обратима: можно выбрать нужный байт в жертве, обратным ходом вычислить пиксельное значение, которое его даёт, и положить это значение в файл.
Второй путь входа и реальный iPad
Помимо пути «покажи миниатюру», уязвимость срабатывает в любом конвейере, который конвертирует EXR, запрашивая тот же выходной формат со стандартным динамическим диапазоном: открыть файл, попросить на выходе PNG или JPEG — и декодирование пройдёт через ту же переполняющуюся процедуру по пути. Конвертация происходит постоянно и незаметно в современной ОС, что резко расширяет поверхность атаки.
В случае с iMessage вложение EXR записывается на диск, индексируется и передаётся в фото-подсистему, где фоновый рабочий процесс генерирует производные изображения. Этот процесс строит запрос на декодирование с жёстко включённой опцией SDR — не условно, а безусловно, — и ImageIO направляет EXR в libAppleEXR, который выполняет декодирование.
Первый значимый результат на реальном устройстве: декодер сработал на файлах, пришедших обычными средствами, а не размещённых вручную. Системная обработка изображений, выполняя обычную автоматическую работу с входящим контентом, вошла в переполнение. Отчёты о сбое пришли из собственных процессов Apple, с ошибкой в CompressedInterleave4 по тому же смещению +452, причём адрес сбоя находился на один байт за реальной областью кучи.
Насколько всё плохо
Запись 16 байт на пиксель в 12-байтовый буфер происходит внутри привилегированного фонового демона, который занимается индексацией вложений для поиска и генерацией миниатюр в фотобиблиотеке. Декодирование происходит позже, в другом демоне, когда сохранённое вложение индексируется для поиска.
Это переполнение детерминированное, и три четверти записываемых байт контролируются атакующим. При уменьшении изображения так, чтобы назначение попало в малые и крошечные зоны аллокатора, и при подготовке соседних объектов запись перестаёт быть грубым переполнением и становится записью-куда-угодно: байт записывается по выбранному адресу. Подтверждение — падение по адресу 0x67e03bd12a4a594e, который не является ни указателем кучи, ни слайдомслучайное смещение базового адреса, на которое система сдвигает загрузку кода и данных при каждом запуске, а выбранным значением, к которому направлена запись.
Из произвольной записи в контролируемом стенде удавалось надёжно получить управление счётчиком команд: адаптировать слот указателя на функцию, сработать — и исполнение перенаправляется туда, куда целились, 100 запусков из 100.
Что доказано: управляемое переполнение кучи, срабатывающее zero-click внутри привилегированного демона, с детерминированной посадкой, тремя четвертями контролируемых байт и достижимостью через iMessage без взаимодействия. Что остаётся недоказанным: переход от стенда к реальной жертве через границу процесса. Pointer Authenticationподписывание указателей на функции, не позволяющее подделать цель без отдельной утечки информации подписывает указатели на функции, поэтому без отдельной утечки информации чистый неассистированный кросс-процессный пролом остаётся тяжёлой задачей. Кроме того, на новейшем кремнии привилегированный демон в zero-click пути работает с включённой аппаратной маркировкой памяти, из-за чего переполнение вызывает срабатывание проверки тега, а не тихое повреждение. Apple классифицировала отчёт как «0-click code execution via iMessage EXR image payload».
Что из этого следует на практике
Для защитников. Аппаратное тегирование памяти включено в привилегированном фоновом демоне на новейшем кремнии — там переполнение вызывает срабатывание проверки тега, а не тихое повреждение. Но то же тегирование не включено на foreground-процессе Photos, а на всём парке устройств до последнего поколения такого аппаратного тегирования нет вовсе. Переполнение срабатывает одинаково и на железе с полностью выключенным тегированием — та же инструкция, тот же класс сбоя. Мера защиты, покрывающая один демон на новейшем кремнии, не покрывает весь парк устройств.
Для багхантеров. Краш в фоновом демоне — это security event, а не stability blip: он показывает, что недоверенный контент доходит до чувствительного декодера без участия пользователя. Фоновые краши в путях декодирования были доказательством zero-click-достижимости — они происходили, когда ничего не было на переднем плане и никто не трогал устройство. Телеметрия, которая трактует падение фонового воркера в декодере изображений как шум, выбрасывает сигнал тревоги. При этом сам по себе segfault не является уязвимостью — это приглашение к разбору.
О воронке декодирования. ImageIO / CGImageSource — это воронка, через которую проходит каждое изображение на платформе, включая EXR. Apple поставляет собственный декодер libAppleEXR.dylib, подключённый к ImageIO, поэтому он стоит за той же воронкой CGImageSource, что декодирует практически любое изображение. Если EXR декодируется там, он декодируется везде, где ImageIO просят открыть изображение. Поверхность атаки расширяется ещё и потому, что декодирование запускается не только по пути «покажи миниатюру», но и при любой конвертации EXR с запросом стандартного динамического диапазона.
О компромиссе удобства и attack surface. Автоматическая обработка вложений — это архитектурный выбор в пользу удобства: индексация, поиск и генерация миниатюр нужны, чтобы фототека оставалась быстрой и доступной поиском. Плата за это — расширенная поверхность атаки: каждый шаг автоматической обработки есть парсер над контролируемыми атакующим байтами в привилегированном демоне. Защиты вроде PAC и аппаратной маркировки памяти ограничивают эксплуатацию, но не устраняют саму автоматическую обработку.
Таймлайн и раскрытие
В мае 2026 года уязвимость была передана в Apple Security с полным описанием, воспроизводимым примером, специально подготовленными файлами-триггерами и артефактами краха на устройстве, включая артефакты фонового режима, доказывающие достижимость без взаимодействия.
В мае и июне 2026 года были направлены дополнительные материалы: подтверждение на другом оборудовании (ошибка проявляется одинаково на кремнии с отключённым тегированием памяти), эскалация от линейного переполнения до произвольной записи по выбору атакующего и управление счётчиком команд на уровне стенда.
Триаж Apple классифицировал отчёт как 0-click code execution via iMessage EXR image payload. В сентябре 2026 года ошибка была исправлена в iOS, iPadOS и macOS 27 (Golden Gate) как CVE-2026-86869.