В Excelize нашли DoS: файл в 3 КБ занимает OpenFile на минуту
Библиотека уходит в расшифровку по первым восьми байтам файла, даже если пароль не задан. Что делать, пока исправления нет.

В Excelize, Go-библиотеке для чтения и записи файлов Excel (около 21 тысячи звёзд на GitHub), опубликована уязвимость отказа в обслуживании. Файл размером 3 072 байта заставляет OpenFile крутить цикл выработки ключа столько раз, сколько указано в самом файле: при значении 100 000 000 вызов на версии 2.11.0 занимает 58,65 секунды и только потом возвращает ошибку «zip: not a valid zip file». Уязвимы все версии с 2.3.1 по 2.11.0, исправления на момент публикации нет, оценка CVSS 7,5.
Под угрозой сервисы, которые открывают недоверенные файлы через Excelize: импорт таблиц, конвертеры и обработчики отчётов. Ограничение размера загрузки само по себе не устраняет проблему, поскольку приведённый исследователем файл занимает всего 3 072 байта. До исправления отчёт предлагает отсеивать неподдерживаемые зашифрованные контейнеры или изолировать обработку по времени.
Ключевые выводы
- Причина: если первые восемь байт файла совпадают с сигнатурой OLE, Excelize идёт по ветке расшифровки независимо от того, задан ли пароль.
- Функция convertPasswdToKey выполняет spinCount итераций хеширования до проверки верификатора, а spinCount берётся из XML-потока EncryptionInfo без ограничений.
- Стоимость линейная, около 0,6 микросекунды на итерацию: 1e8 даёт около минуты, 1e9 около десяти минут; память при этом держится на 24 МБ.
- На пути нет context.Context, поэтому вызывающий код не может отменить операцию; после ответа HTTP-обработчика по таймауту горутина продолжит вычисление.
- Excel и LibreOffice обычно записывают spinCount 100 000; в отчёте такой вызов занимает 61 миллисекунду. Автор предлагает ограничить счётчик при разборе файла. Сообщил исследователь arpitjain099; уведомление опубликовано 6 сентября, идентификатора CVE и патча пока нет.
Как файл на три килобайта занимает библиотеку на минуту
Зашифрованные паролем книги Excel хранятся в OLE-контейнере, внутри которого лежит поток EncryptionInfo с параметрами шифрования и сам зашифрованный пакет. Excelize определяет такой файл по первым восьми байтам: функция openReaderAt ветвится только по заголовку и не смотрит, передал ли вызывающий код пароль в опциях. Дальше agileDecrypt вызывает convertPasswdToKey, где ключ выводится из пароля повторным хешированием; число повторов задаёт поле spinCount, и проверка хеша-верификатора идёт уже после этого цикла.
Само поле spinCount в коде объявлено как обычный int и заполняется голым xml.Unmarshal из потока файла. Никакой верхней границы нет, поэтому атакующий сам выбирает, сколько времени займёт вызов. Автор отчёта проверил это на выпущенных версиях с proxy.golang.org без директив replace:
Цикл появился вместе с файлом crypt.go в версии 2.3.1 и с тех пор не менялся. Память во время атаки остаётся плоской, около 24 МБ, поэтому её нельзя отловить ни лимитом памяти, ни OOM-киллером: процесс просто занимает ядро на выбранное атакующим время. Автор отмечает, что уязвимость касается только доступности и не угрожает программам, которые открывают файлы, созданные их же оператором.
Чем защититься, пока нет патча
Самый простой фильтр стоит на границе: если сервис не поддерживает зашифрованные книги, отбрасывайте файлы, которые начинаются с OLE-сигнатуры D0 CF 11 E0 A1 B1 1A E1, до вызова Excelize. Обычный XLSX это ZIP-архив и начинается с 50 4B 03 04. Такая проверка занимает одну строку и полностью закрывает вектор для сервисов без паролей.
Проверьте используемую версию Excelize в зависимостях проекта. В сохранённом advisory уязвимыми названы версии с 2.3.1 по 2.11.0 включительно, поле исправленных версий пусто. Следить за появлением патча следует по тому же advisory и релизам проекта; отсутствие предупреждения отдельного сканера само по себе не подтверждает безопасность зависимости.
Если зашифрованные файлы нужны, разбор стоит вынести в отдельный процесс или воркер с жёстким лимитом времени: context.Context на этом пути Excelize не поддерживается; остановить горутину снаружи невозможно. Полезно также разобрать EncryptionInfo самостоятельно и отклонить файлы, где spinCount больше, скажем, миллиона: Excel и LibreOffice обычно используют 100 000. Автор отчёта предлагает мейнтейнерам такой потолок на этапе парсинга.
Таймаут должен ограничивать именно вычисление. Возврат ошибки клиенту по истечении времени ожидания не останавливает цикл в уже запущенной горутине: в описанном пути нет механизма отмены. Изоляция в отдельном процессе позволяет завершить обработчик целиком, если он вышел за лимит. Это временное ограничение последствий; проверка недоверенного счётчика внутри библиотеки остаётся предлагаемым исправлением причины.
Почему это типовой класс ошибок для парсеров
Схема одна и та же: формат позволяет файлу самому объявить, сколько работы предстоит парсеру, а парсер верит. Вышло исправление libheif 1.23.4, где файл с неограниченным числом элементов давал квадратичное время разбора и полтора гигабайта памяти. У Excelize ситуация проще: нет ни рекурсии, ни аллокаций, только счётчик из недоверенного XML. Следить за исправлением стоит в репозитории проекта: в advisory должна появиться исправленная версия, после чего можно будет обновить зависимость и проверить импорт. О диагностике горутин в работающем сервисе рассказывает новый профиль в Go 1.27, а о девяти уязвимостях в curl того же класса мы писали на прошлой неделе.
Источники: GHSA-jrfj-fhj2-jjvm: Unbounded spinCount in agile decryption burns CPU during OpenFile, Репозиторий qax-os/excelize
Изображение на обложке: Excelize, логотип проекта












