Что случилось с Cloudflare и при чем здесь разработка безопасного ПО?

18 ноября 2025 года в 11:20 UTC в сети Cloudflare начались серьезные сбои в процессах доставки сетевого трафика. Пользователи по всему миру, пытаясь получить доступ к сайтам, использующим эту платформу, получали сообщение о сбое в сети Cloudflare.

Страница с ошибкой HTTP, отображаемая во время инцидента
Страница с ошибкой HTTP, отображаемая во время инцидента

Из-за этого сбоя пропал доступ ко множеству ресурсов, таких как Indeed, Uber, Canva, Spotify, соцсетям и к ChatGPT тоже. Несмотря на всю масштабность инцидента, через пару недель он уже сошел с первых полос и почти канул в прошлое… Однако в начале декабря Cloudflare снова оказался в центре внимания — и, кажется, по тем же ��ричинам. Снова были зафиксированы массовые перебои в работе интернет‑ресурсов, компания официально задекларировала «внутреннюю деградацию сервисов».  К слову, «лежал» и сам DownDetector, служащий для отслеживания сбоев.

Сбои происходили по всему миру. Правда, в России, где использование данного сервиса не рекомендовано, число жалоб на порядок ниже среднемирового: по данным издания «Коммерсант», на сбои в работе платформы пожаловались 384 пользователя из России, в Британии – 5,5 тысяч пользователей, в Нидерландах — 3 тысячи, в США и Германии — по 2 тысячи, во Франции — 1,6 тысячи.

Два случая — уже тенденция, поэтому и сегодня, спустя некоторое время после инцидентов, эта тема достойна обсуждения. Тем более, что она наглядно иллюстрирует ответ на вопрос: «Да зачем она вообще нужна, эта ваша “безопасная разработка”?», — который, к сожалению, все еще актуален. Представляю вашему вниманию небольшой анализ причин сбоев на основе сообщений Cloudflare.

18 ноября в официальном блоге Cloudflare был опубликован комментарий на нескольких языках, включая русский. Приведу две ключевые цитаты.

  1. «По мере постепенного развертывания явных разрешений для пользователей определенного кластера ClickHouse, после изменения в 11:05 вышеуказанный запрос начал возвращать «дубликаты» столбцов, поскольку они относились к базовым таблицам, хранящимся в базе данных r0».

2.     «Каждый модуль, работающий на нашем прокси-сервисе, имеет ряд ограничений для предотвращения неограниченного потребления памяти и предварительного выделения памяти в целях оптимизации производительности. В данном конкретном случае система управления ботами имеет ограничение на количество функций машинного обучения, используемых во время выполнения. В настоящее время это ограничение установлено на 200, что значительно превышает текущее использование около 60 функций. Опять же, ограничение существует, поскольку для повышения производительности мы предварительно выделяем память для функций.

Когда вредоносный файл, содержащий более 200 функций, был распространен на наши серверы, это привело к превышению лимита и системной панике. Ниже представлен код Rust FL2, который выполняет проверку и является источником необработанной ошибки:

код, который сгенерировал ошибку
Рисунок 1 – "Обработка ошибок", по версии Cloudflare.

Это вызвало следующую панику, что, в свою очередь, привело к ошибке 5xx:

thread fl2_worker_thread panicked: called Result::unwrap() on an Err value»

Итак, исходя из приведенных цитат, мы видим несколько ошибок, допущенных при разработке ПО. Первая, в логике программы, описана следующим образом:

«До настоящего времени пользователи CheckHouse могли видеть таблицы в базе данных по умолчанию только при запросе метаданных таблиц из системных таблиц СlickHouse, таких как системные.tables или системные.columns.

Поскольку у пользователей уже есть неявный доступ к базовым таблицам в r0, мы внесли изменение в 11:05, чтобы сделать этот доступ явным. Теперь пользователи также могут видеть метаданные этих таблиц. <…> В результате описанного выше изменения все пользователи получили доступ к точным метаданным таблиц, к которым у них есть доступ. К сожалению, в прошлом делались предположения, что список столбцов, возвращаемый подобным запросом, будет включать только базу данных “по умолчанию”».

Ключевое слово – «предположения»! А на чем они основаны и чем проверены?

SELECT name, type FROM system.columns WHERE table = 'http_requests_features' order by name;

«Обратите внимание, что запрос не фильтрует по имени базы данных. По мере постепенного развертывания явных разрешений для пользователей определенного кластера ClickHouse, после изменения в 11:05 вышеуказанный запрос начал возвращать «дубликаты» столбцов, поскольку они относились к базовым таблицам, хранящимся в базе данных r0.

К сожалению, именно такой тип запроса выполнялся логикой генерации файла признаков управление ботами для создания каждого входного «признака» для файла, упомянутого в начале этого раздела. <…>

Однако в рамках дополнительных разрешений, предоставленных пользователю, ответ теперь содержал все метаданные схемы r0, что фактически более чем вдвое увеличило количество строк в ответе, в конечном итоге повлияв на количество строк (т.е. функции) в конечном выходном файле».

Что ж, вот тот камешек, который стронул лавину: ошибка в логике программы.

Далее из блога мы узнаем о «периодичности» в возникновении ошибки.

«Объяснение заключалось в том, что файл генерировался каждые пять минут запросом, выполняемым в кластере баз данных ClickHouse, который постепенно обновлялся для улучшения управления разрешениями. Недопустимые данные генерировались только в том случае, если запрос выполнялся на части кластера, которая была обновлена. В результате, каждые пять минут существовала вероятность генерации и быстрого распространения по сети как работоспособных, так и вредоносных файлов конфигурации».

Отсюда можно сделать предположение, что в коде, который реализовывал эту функцию, присутствовала и неинициализированная переменная, оказывавшая влияние на фактор случайности сбоя. Кстати, это тоже распространенная ошибка — неинициализированная переменная: при компиляции, как правило, ее наличие сопровождается предупреждением вроде «Variable might be not initialized» (но ведь это всего лишь предупреждение – кто их читает?). Пусть даже при первом использовании переменной в нее, как правило, записан null. А если ее уже использовали и забыли сбросить — насколько будет предсказуем результат?

Выявить эту ошибку можно было бы тестированием, проведенным в условиях, приближенных к боевым, или «канареечным» релизом. Но, судя по всему, разработчиками решили: все хорошо, мы уже не раз так делали. А реальность сильно отличалась от IDE разработчика.

Это, конечно, плохо. Но такого масштаба катастрофы можно было бы избежать — если бы Cloudflare следовал практикам безопасного кода.

Обратите внимание на цитату под номером 2. Мы видим, можно сказать, детские ошибки, описанные во всех учебниках по безопасному программированию:

1.     Жесткое кодирование размера массива при неопределенной размерности входных данных;

2.     Отсутствие локального обработчика ошибки (исключения), который вызываеется в месте вызова функции, способной породить ошибку или исключительную ситуацию (CWE 248, 680, 703, OWASP ASVS 11);

3.     Отсутствие глобального обработчика исключений, «финального», если можно так выразиться, препятствующего краху всей программы при возникновении необработанного исключения (CWE 248, 396, 397, OWASP ASVS 12).

Именно эти ошибки привели к лавинообразному нарастанию сбоя. Рассмотрим их подробнее.

Ошибка №1: Жесткое кодирование размера массива при неопределенной размерности входных данных.

Из описания следует, что максимальное количество строк, которое могло быть считано из файла, равно 200. После того, как следовала попытка считать 201-ю строку, поведение программы становилось неопределенным из-за ошибок №2 и №3. Ниже я рассмотрю несколько иллюстративных примеров.

Как такой небезопасный код обычно выглядит на практике:

Этот пример написан на старом добром Delphi, который не даст вам «выстрелить себе в ногу» и достаточно легко читается, практически как инструкция на английском языке, вне зависимости от того, на каком языке программирования вы работаете.

Отметим, что код скомпилирован без единой ошибки с точки зрения весьма неплохого компилятора Delphi. Что же при выполнении этого кода может пойти не так?

Практически все.

Предположим, что мы считали ту самую 201 строку. Что произойдет при увеличении значения переменной BaseLineIndex до 201? Как минимум, возникнет исключение, связанное с попыткой доступа за пределы размерности массива.

Как мы видим, при первом запуске, будучи применена к файлу с менее 200 строк, программа выполнила ровно то, что от нее и ожидали: вывела количество строк.

А вот во втором случае, когда число строк превысило заданное, она просто аварийным образом прервала свое выполнение, указав на ошибку при попытке доступа к данным за пределами массива. Вот так из-за пустяка и падает пол-интернета!

В некоторых случаях можно даже написать код, который заставит программу считывать значения в память и за пределы массива строк, что как минимум приведет к повреждению памяти, а как максимум — можно даже и вредоносный код выполнить.

Лирическое отступление:

В годы моей учебы в Донецком политехе один преподаватель, принимая лабораторные по программированию, особенно по системному, любил вводить случайные последовательности на вход программ или отщелкивать замки дисководов, если они работали с файлами или на системном уровне…

И горе тому, чья программа эти ситуации не обработала правильно…

Вы можете сказать: «А, это древний никому не нужный Delphi…». Хорошо. Вот какой код выдает DeepSeek по просьбе написать программу для чтения 200 строк на Rust:

use std::fs::File;

use std::io::{self, BufRead, BufReader};

fn main() -> io::Result<()> {

    // Открываем файл

    let file = File::open("input.txt")?;

    let reader = BufReader::new(file);

    // Создаем вектор для хранения строк

    let mut lines = Vec::with_capacity(200);

    // Читаем строки из файла

    for line in reader.lines().take(200) {

        lines.push(line?);

    }

    // Выводим результат

    println!("Прочитано строк: {}", lines.len());

    for (i, line) in lines.iter().enumerate() {

        println!("{}: {}", i + 1, line);

    }

    Ok(())

}         

А результат его работы, допустим, при отсутствующем файле — источнике данных — выглядит не лучше, чем для Delphi:

Error: Os {code: 2, kind: NotFound, message: "No such file or directory"}

[Execution Finished]}      

Программа так же аварийным образом завершилась с «системным» сообщением об ошибке.

Ошибки №2 и №3

Вернемся к отсутствию корректной обработки возникающих при выполнении кода исключительных ситуаций и ошибок. Их нет в вышеприведенном коде, что на Delphi, что на Rust, и любая из этих программ рухнет при возникновении ошибки или, как минимум, войдет в неопределенное состояние, что мы и видели на примере Cloudflare:

thread fl2_worker_thread panicked: called Result::unwrap() on an Err value

По сути, тот обработчик, что существовал в Cloudflare, не имел большого смысла, поскольку не способствовал восстановлению после ошибки или стабилизации поведения программы, как минимум, не предоставлял понятную диагностику.

Как было бы правильно? Рассмотрим более безопасный пример решения задачи чтения файла (на условном Delphi-подобном языке программирования).

//Определим переменную (если нужно) для индекса последней строки данных:

Uint64 SourceLinesSize = 0; // 64-битное целое, и обнулим ее

MyStringList : TStringList; // Определим массив данных типа «массив строк»

//…

// Проверим, что корректно ли имя файла (соответствует ли синтаксису, не содержит ли «левые» символы и пр.)

If isFileNameCorrect(SourceFileName) <> FileNameIsCorrect Begin // Неверное имя файла

                                                                                                      Return(IncorrectFilename);

// Имя файла неверно, выходим с соответствующим кодом возврата, который проверим в //вышестоящей вызывающей функции. Дальше можно ничего не делать 

                                                                                                      End

// Проверим, доступен ли этот файл (может быть совмещено с предыдущей проверкой)

If isFileExists(SourceFileName) <> FileIsExists Begin // Файл недоступен

                                                                              Return(FileIsDisabled);

// Файл отсутствует, выходим с соответствующим кодом возврата, который проверим в

// вышестоящей вызывающей функции. Дальше можно ничего не делать.

                                                                              End

//Файл есть и доступен, создадим (безопасно) структуру данных для строк

Try // Глобальная обертка для обработки исключений

MyStringList = TStringList.Create;

If MyStringList <> nil Then Begin// Успешно создали структуру

                                    Try // Локальная обертка безопасности для чтения из файла

                                    MyStringList.LoadFromFile; // Грузим строки из файла

                                    SourceLinesSize = MyStringList.Count;

                                    Except //Так не очень правильно, но для простоты ограничимся обработкой //исключения «вообще», без конкретики

                                    Return(FileReadingError); //Выходим наверх с ошибкой чтения файла

                                    End // Окончание обработки сбоя при чтении

                                               End // Структура успешно создана и данные в нее считаны

                                    Else Begin // Не удалось создать структуру данных

                                            Return(CreateDataError);

                                            End

Except // Если не удалось создать объект данных, или не обработался сбой при чтении, или еще что-//то вообще пошло не так…

Return(CommonError); //Выходим наверх с общей неопределенной ошибкой. Это серьезная //проблема

End; // Окончание обработки исключения

Да, наш код стал гораздо больше и сложнее. Относительно безопасный, корректно обрабатывающий основные критические моменты, которые могут возникнуть в процессе выполнения программы, код на ЯП Rust составит примерно 180 строк — сравните сами с тем, что мы видели выше.

Но он стал гораздо безопаснее: он безопасно работает с файлами, он безопасно работает с ресурсами в памяти, он возвращает ситуационно определенные коды ошибок, позволяющие понять, что пошло не так и провести восстановление после сбоя. Этим он отличается от кода CloudFlare, который практически полностью соответствует критерию «быстро и грязно».

«Хорошо, — скажете вы, — а можно ли как-то бороться с этим, есть ли какие-то методики, инструменты?» Да, можно.

Некоторые небезопасные конструкции (а я привел лишь очень малое число примеров таковых) — отлавливаются с использованием статических анализаторов кода, того же appScreener, например.

Некоторые — с использованием fuzzing-тестирования, и я очень люблю эту методику отлова багов.

Но все-таки я предпочитаю и считаю правильным использовать эти методы в комбинации: статический анализатор, appScreener или SVACE, и fuzzing-тест на критические функции, в особенности те, что относятся к поверхности атаки.

Подведем черту

Даже использование вышеописанного инструментария тестирования не гарантирует полного 100% эффекта и безопасного кода. Разработать безопасный код не так уж и просто, даже с использованием ИИ: ведь для того, чтобы бездушная железка написала безопасный код, она должна знать, какой код считать безопасным, следовательно, ей нужно дать именно такое задание.

А кто это делает? Вы.

Значит, вы сами и должны знать все ответы на эти вопросы. Самый лучший, первейший инструмент безопасности — вы, ваш мозг, наполненный знаниями и навыками их применения в практической работе.

Вот эт�� и лежит в основе разработки безопасного ПО — такой, какой она должна быть.

А иначе — пример Cloudflare прямо перед вами.

Автор: Егор Изотов, эксперт департамента архитектуры стратегических проектов центра противодействия кибератакам Solar JSOC, ГК «Солар»