Материал подготовлен в рамках курса «Системное программирование»
Содержание
Предыстория
Процесс разработки
Усиление защиты контейнера и исполняемого файла
Запуск внутри контейнера от непривилегированного пользователя
Защита от непреднамеренного повышения привилегий процесса через PR_SET_NO_NEW_PRIVS
Атаки на HTTP/2
Варианты облачных провайдеров
Cloudflare
bunny.net
Google Cloud Run
Итоги
Бонус: утечка памяти
Мне хотелось лучше разобраться в HTTP/2 и заодно попробовать C++23, поэтому я решил написать HTTP/2-сервер с нуля, используя RFC как основной источник информации. Пока это оказался очень увлекательный проект, но какой тест может быть лучше, чем бросить его на растерзание интернету и самому же использовать для своего личного сайта?
Контента на моём сайте, конечно, не море, но проверка сервера на реальном трафике и размышления о том, что может пойти не так — как по вине обычных пользователей, так и из-за злонамеренных действий, — дали немало интересных выводов о том, как защищать по своей природе небезопасные приложения от атак. Небезопасные — потому что они обрабатывают внешний ввод из интернета. И ещё потому, что это C++, а значит, с точки зрения памяти всё фундаментально небезопасно.
Наверняка у вас уже возник вопрос: почему я не написал это на Rust? Я хотел изучить C++ для разработки аудиоприложений, но, скорее всего, когда-нибудь доберусь и до Rust. К тому же мне кажется, что опыт с C++ поможет лучше понять Rust.
Процесс разработки
Я разрабатывал Ion, придерживаясь lean-подхода и TDD. Начал с самой базовой функциональности — возвращать :status: 200 на любой HTTP-запрос, — а затем постепенно наращивал возможности. Сам сервер написан на C++, но системные тесты я делал на Python и проверял сервер стандартными HTTP/2-клиентами: сначала через curl, потом с помощью Python-клиентов для HTTP/2, чтобы тестировать более специфичное поведение, в частности h2hyper и httpx. Для бенчмаркинга и проверки конкурентной обработки запросов я использовал h2load из nghttp2.
В итоге у сервера появилась довольно простая, но расширяемая основа для обработки запросов:
#include "ion/http2_server.h" int main() { ion::Http2Server server{}; auto& router = server.router(); router.add_route("/", "GET", [](const ion::HttpRequest&) { const std::string body_text = "hello"; const std::vector<uint8_t> body_bytes(body_text.begin(), body_text.end()); return ion::HttpResponse{ .status_code = 200, .body = body_bytes, .headers = content-type}; }); server.start(8443); return 0; }
Но такие искусственные тесты могли продвинуть разработку только до определённого предела. Мне хотелось проверить сервер в браузерах и заодно посмотреть, как он поведёт себя за прокси Google, Cloudflare и других сервисов, которые в итоге окажутся перед ним. Для этого я решил собрать всё необходимое, чтобы захостить лендинг и контент своего сайта. Пришлось добавить поддержку статических файлов, чтобы можно было упаковать сайт в любую среду, которая согласится принять мой скромный проект.
Перед тем как бросать сервер на растерзание, я также потратил время на усиление защиты логики HTTP/2-сервера, собранного бинарника и контейнерного образа.
Усиление защиты контейнера и исполняемого файла
Я хотел упаковать сервер в контейнер: это даёт заметные преимущества с точки зрения безопасности и удобства деплоя. У большинства облачных провайдеров есть какой-нибудь вариант хостинга контейнеров, а меня особенно интересовали serverless-решения — в основном потому, что это самый простой путь.
Ещё я не хотел сам администрировать ОС, учитывая, насколько рискованную нагрузку собирался запускать. Облачные провайдеры уже хорошо умеют изолировать контейнеризованные рабочие нагрузки: через microVM с Kata Containers или варианты вроде gVisor, где Linux-ядро эмулируется в user space. Это закрывает часть рисков, связанных с уязвимостями ядра, но мне всё равно нужно было учитывать возможность удалённого выполнения кода и вообще любых плохих действий внутри контейнера.
С учётом этих рисков я использовал следующие приёмы, чтобы уменьшить потенциальный радиус поражения:
Запуск внутри контейнера от непривилегированного пользователя
Довольно очевидная мера безопасности, которую по-хорошему стоит применять к любой контейнеризованной нагрузке. Она снижает вероятность побега из контейнера и уменьшает шансы успешно проэксплуатировать уязвимости ядра.
USER ion
2. Использование scratch как базового образа
Для запуска Ion нужны только сам исполняемый файл, контент, который сервер будет отдавать, и бинарники библиотек, от которых он зависит: например, OpenSSL, libc++ и так далее. Если собрать образ сервера на базе scratch, мы получаем полностью пустую файловую систему. Без shell, без /bin, без /lib. Только то, что мы сами явно добавили. Это сильно ограничивает возможности атакующего, даже если он получит возможность вызвать execve внутри контейнера. Попробуйте что-нибудь выполнить, когда вокруг нет исполняемых файлов.
Минус, правда, тоже есть: при использовании scratch инструменты для сканирования Docker-образов на уязвимости, например встроенные в Google Artifact Registry (GAR), перестают распознавать включённые пакеты. По сути, они не могут определить Linux-дистрибутив и пакетный менеджер, потому что это не дистрибутив, а просто набор сырых бинарников.
В качестве обходного пути можно отправлять в GAR сам build-stage. Такой образ уже можно просканировать, потому что библиотеки в конечном счёте берутся именно оттуда.
Чтобы понять, что именно нужно скопировать в контейнерный образ, я использовал вывод ldd build/app/ion-server и по нему динамически подтягивал зависимости в новую корневую файловую систему. Например, на ARM64-сборке Debian ldd вывел:
linux-vdso.so.1 (0x0000e4f3b8db6000) libc++abi.so.1 => /lib/aarch64-linux-gnu/libc++abi.so.1 (0x0000e4f3b8c10000) libssl.so.3 => /lib/aarch64-linux-gnu/libssl.so.3 (0x0000e4f3b8b40000) libcrypto.so.3 => /lib/aarch64-linux-gnu/libcrypto.so.3 (0x0000e4f3b8600000) libc++.so.1 => /lib/aarch64-linux-gnu/libc++.so.1 (0x0000e4f3b84e0000) libunwind.so.1 => /lib/aarch64-linux-gnu/libunwind.so.1 (0x0000e4f3b8b10000) libm.so.6 => /lib/aarch64-linux-gnu/libm.so.6 (0x0000e4f3b8430000) libgcc_s.so.1 => /lib/aarch64-linux-gnu/libgcc_s.so.1 (0x0000e4f3b8ad0000) libc.so.6 => /lib/aarch64-linux-gnu/libc.so.6 (0x0000e4f3b8270000) /lib/ld-linux-aarch64.so.1 (0x0000e4f3b8d79000)
Вот соответствующий фрагмент app.Dockerfile:
# ... приложение скомпилировано в build/app/ RUN mkdir -p /runtime-root/etc /runtime-root/lib /runtime-root/usr/lib \ /runtime-root/app RUN ldd build/app/ion-server | grep "=>" \ | awk '{print $3}' \ | xargs -r cp -L --parents -t /runtime-root/ RUN ldd build/app/ion-server | grep -v "=>" \ | grep -v "linux-vdso" \ | awk '{print $1}' \ | xargs -r cp -L --parents -t /runtime-root/ RUN cp /etc/ssl/certs/ca-certificates.crt /runtime-root/etc/ RUN echo "ion:x:1000:1000:ion:/app:/usr/sbin/nologin" > /runtime-root/etc/passwd RUN echo "ion:x:1000:" > /runtime-root/etc/group RUN cp build/app/ion-server /runtime-root/app/ion-server FROM scratch COPY --from=builder /runtime-root / USER ion WORKDIR /app EXPOSE 8443 ENTRYPOINT ["/app/ion-server"]
3. Защита от непреднамеренного повышения привилегий процесса через PR_SET_NO_NEW_PRIVS
Да, мы уже убрали из контейнерного образа другие бинарники вроде sudo и прочие возможные setuid-цели, но всё равно не помешает запретить ядру повышать привилегии процесса или его дочерних процессов сверх того, что было выдано при запуске.
Для этого я использовал следующий код, который запрещает выдачу новых привилегий:
bool ProcessControl::enable_no_new_privs() { #ifdef __linux__ if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) == -1) { spdlog::warn("failed to set PR_SET_NO_NEW_PRIVS: {}", std::strerror(errno)); return false; } spdlog::debug("PR_SET_NO_NEW_PRIVS enabled"); return true; #else return false; #endif }
4. ASLR и read-only GOT — рандомизация адресного пространства и таблицы глобальных смещений, защищённые от записи
ASLR делает адреса символов непредсказуемыми при каждом запуске процесса. Поэтому, даже если атакующий получит возможность удалённого выполнения кода внутри процесса, ему будет практически невозможно просто «прыгнуть» по нужному адресу. В macOS ASLR включён по умолчанию, а при сборке под Linux его можно включить отдельно. Но хотя ASLR усложняет угадывание адресов символов, утечка информации — например, фрагмента кода из какой-нибудь библиотеки — может помочь понять, где эта библиотека находится в памяти.
А значит, дать атакующему подсказку, куда прыгать, если он захочет попасть в процедуру из этой библиотеки. Так же может раскрыться расположение стека или кучи. ASLR включается флагом компилятора -fPIE и флагом линковщика -pie.
GOT содержит указатели на функции, используемые для динамической линковки. Таблицу можно сделать read-only, чтобы предотвратить атаки с перезаписью её записей — например, когда puts подменяют на system. Обычно этот приём используют вместе с ASLR, чтобы усилить защиту бинарника от уязвимостей удалённого выполнения кода. Включается это флагами линковщика -z,relro и -z,now — см. CMakeLists.txt:
target_compile_options(ion-server PRIVATE -fPIE) if (NOT APPLE) target_link_options(ion-server PRIVATE -pie "LINKER:-z,relro" "LINKER:-z,now" ) endif ()
5. Удаление символов из бинарника
Если не включать в бинарник отладочную информацию о символах, становится сложнее — хотя и не невозможно — разобраться через реверс-инжиниринг, что делает загруженная программа. В конечном счёте Ion open source, и любой может воспроизвести скомпилированный бинарник, но для release-сборок всё равно хорошая практика — удалять символы.
ASLR, GOT, динамическая линковка и устройство исполняемого файла — лишь часть системного уровня, который обычно остаётся скрыт за кодом. На курсе «Системное программирование» подробно разбирают, как программа проходит путь от CPU и памяти до ядра ОС, системных вызовов и ELF.
Атаки на HTTP/2
Само по себе писать веб-сервер ради обучения — это, конечно, прекрасно, но в таких проектах защитное программирование обычно не стоит в самом верху списка приоритетов. Тем не менее, хотя я уверен, что не защитил сервер от всех известных уязвимостей HTTP/2, кое-какие меры я принял, чтобы предотвратить наиболее очевидные злоупотребления протоколом. Кроме того, поскольку в итоге перед сервером стоит CDN, меня немного успокаивает, что часть защиты берёт на себя и он.
Какие меры я предпринял:
Проверять, что TLS-handshake, HTTP/2 preface и передача данных происходят достаточно быстро, и закрывать соединение по таймауту, если это не так. Ещё я добавил idle connection reaper, который закрывает соединения, если через
epollилиpollпо ним какое-то время не отправлялись и не принимались данные.Жёстко ограничить размер HTTP/2-фрейма до 16 КБ.
Ограничить размер динамических таблиц HPACK до 64 КБ.
Ограничить размер read buffer до 256 КБ.
Ограничить размер строк, используемых в именах и значениях заголовков, до 4 КБ.
Ограничить количество байт, которые читаются при декодировании целых чисел. Технически механизм представления integer, описанный в RFC 7541, позволяет атакующему отправлять непрерывный поток нулей: итоговое число при этом не переполнится, а сервер будет просто тратить CPU, пока не упрётся в какой-нибудь другой лимит, например в размер фрейма.
Жёстко ограничить число одновременных соединений до 128 и отбрасывать все последующие входящие подключения. Не сомневаюсь, что сервер точно способен выдержать больше, но для хостинга моего сайта за CDN 128 пока более чем достаточно.
Варианты облачных провайдеров
Cloudflare
Cloudflare позволяет запускать контейнеры в рамках Workers. Весь HTTP-трафик можно прокинуть в контейнер с помощью примерно такого Worker-кода:
import { Container, getContainer, getRandom } from "@cloudflare/containers"; import { Hono } from "hono"; export class MyContainer extends Container<Env> { defaultPort = 8080; envVars = {}; } const app = new Hono<{ Bindings: Env; }>(); ... // Распределение нагрузки запросов между несколькими контейнерами app.get("/lb", async (c) => { const container = await getRandom(c.env.MY_CONTAINER, 3); return await container.fetch(c.req.raw); }); export default app;
Однако метод container.fetch() либо не поддерживает запросы по HTTPS, либо не поддерживает HTTP/2, либо и то и другое. Ion падал на TLS-handshake с ошибкой, которая указывала, что клиент пытается установить cleartext-соединение. Когда я перевёл Ion в режим h2c, он уже ругался на некорректный HTTP/2 preface. Не взлетело.

В каком-то смысле это логично с точки зрения продукта. Cloudflare Workers позволяют хостить контейнеры, но скорее как способ предоставить вспомогательный сервис для уже существующего веб-приложения. Поэтому напрямую проксировать HTTP-соединения в контейнер нельзя — по крайней мере через Layer 4 proxying.
bunny.net
Bunny предлагает сервис для хостинга контейнеров под названием Magic Containers. Он умеет хостить и масштабировать набор контейнеров, предоставляя anycast IP endpoint напрямую к этому набору, либо можно поставить перед контейнерным флотом их CDN. Подход с anycast IP endpoint сработал довольно хорошо, а их платформа логирования даже поддерживает ANSI-цвета! ?

Поставить перед контейнером их CDN у меня не получилось, поэтому в качестве CDN пришлось использовать Cloudflare. Похоже, фронтенд Bunny не поддерживает origins с HTTP/2. Быстрый разговор с их поддержкой в Discord подтвердил: сейчас HTTP/2 до backend у них действительно пока не поддерживается.

Google Cloud Run
В GCP есть serverless-решение Cloud Run: оно предоставляет Knative-подобный интерфейс для хостинга контейнеров. Cloud Run очень гибко настраивается и позволяет контейнерам обслуживать запросы через Google Frontend. Хотя снаружи поддерживаются HTTP/1.1, HTTP/2 и HTTP/3, по умолчанию Cloud Run общается между контейнером и frontend по HTTP/1.1. Но его можно настроить на использование HTTP/2. Единственная загвоздка — соединение должно быть cleartext. TLS не поддерживается. До экспериментов с Cloud Run Ion, наоборот, поддерживал только TLS. Пришлось добавить поддержку h2c, чтобы всё заработало.

Итоги
Пока что Ion и мой личный сайт хостятся в bunny.net, а Cloudflare остаётся frontend-слоем: он обеспечивает поддержку HTTP/1.1 и кэширование контента. Посмотреть, как там поживает Ion, можно на status page одного удачливого экземпляра сервера.
Я мог бы захостить Ion в Google Cloud, но поскольку профессионально и так работаю с GCP, мне хотелось попробовать другого провайдера и расширить свой инструментарий. Поэтому я оставил хостинг в bunny.net.
Бонус: утечка памяти
Наверное, никого не удивит, что во время разработки Ion я столкнулся как минимум с одной утечкой памяти. Но, что удивительно, она действительно была единственной. Разумеется, всплыла она там, где пришлось вручную управлять временем жизни C-библиотеки.
Во время разработки я старался максимально использовать современный C++: активно применял RAII, ссылки и smart pointers, чтобы управлять временем жизни C++-объектов. Но при работе с OpenSSL жизненным циклом объектов SSL и SSL_CTX нужно управлять самостоятельно. Утечка была небольшой, но на длинном интервале её было хорошо видно:

Я запускаю debug-сборки с включённым address sanitizer в Clang, а вместе с ним включается и LeakSanitizer (LSan):
if (CMAKE_BUILD_TYPE STREQUAL "Debug") target_compile_options(ion-server PRIVATE -fsanitize=address -fno-omit-frame-pointer -g ) target_link_options(ion-server PRIVATE -fsanitize=address ) ...
Когда я запускал сборки из CLion и прогонял через сервер существенную нагрузку с помощью h2load, результаты LSan показывали, что где-то есть утечка:

Хотя вывод прямо не указывал на SSL или SSL_CTX, он ссылался на символы, которые относятся к OpenSSL. Я уже знал, что неправильно использую SSL_CTX: создавал отдельный объект SSL context для каждого открытого соединения. Я думал, что хотя бы корректно удаляю его в деструкторе TLS transport, но увы. Проблема была исправлена в этом diff:

Оказалось, что SSL_CTX всегда нужно освобождать после создания объекта SSL, а не только если SSL_new() вернул ошибку. Поскольку OpenSSL внутри использует reference counting, так мы гарантируем, что он учитывает только ту ссылку, которую затем держит внутри объекта SSL.
Причина, по которой утечка памяти на графике выглядела такой ровной, заключалась в том, что bunny выполнял TCP health checks контейнера, проверяя, что он жив. Каждый раз, когда TCP-соединение регулярно открывалось и закрывалось, сервер пытался выполнить TLS-handshake, а для этого нужно было создавать объекты OpenSSL.
В конечном счёте этот фикс был заменён нормальной реализацией ion::TlsContext, который уже корректно управлял жизненным циклом SSL_CTX и шарился между всеми TLS-соединениями — как и должно было быть с самого начала.
Если хочется глубже разобраться в управлении ресурсами и памятью в C++, приходите на бесплатные уроки. Преподаватели-практики разберут подходы, которые помогают снижать риск утечек и ошибок при работе с ресурсами.
30 июня, 20:00. «RAII в C++: фундамент надёжного управления ресурсами». Записаться
2 июля, 20:00. «Всё, что нужно знать об управлении памятью в C++». Записаться
Комментарии (6)

bibmaster
08.06.2026 15:51Вообще для сервера кажется не обязательно делать cleartext mode. Для tls порта можно переключаться между http1/h2 через alpn. А для обычного можно проверить h2 preface. А, или параметр просто в принципе отключает tls?

bibmaster
08.06.2026 15:51Кстати, мелочь может, но зачем для body конверсия string -> vector? Даже если данные бинарные чем плох просто string?

bibmaster
08.06.2026 15:51И ещё можете поразвлекаться с h2spec, поищите на github, там базовые проверки соответствия спецификации.

yaroslavp
08.06.2026 15:51То, что самому надо делать преобразование при создании ответа к вектору циферок - кринге. Несколько конструкторов вышли из чата
Sazonov
А можете сделать сравнение с Restinio? - https://habr.com/ru/articles/773608/