ESPNWho's on the CFP bubble? Did Week 8 burst the Big 12's two-bid dreams?Bollywood HungamaAli Abbas Zafar wraps up shooting for Ahaan Panday-Sharvari starrer Beparwah; says returning to YRF feels like “coming back home”Daily MaverickWINNING: Anna Trapido is Food Writer of the YearThe Jerusalem PostIsraeli Hollywood star Gal Gadot votes in 26th Israeli Knesset electionsPunchFRSC sends 19,560 number plates to FCT after delay complaintsUN NewsKenya: 20 Ebola contacts released from quarantineInquirerEstrada’s camp refutes Bonoan’s P7-B insertion claim anewThe Straits TimesDinner will never be Over at competitive eater Zermatt Neo’s new restaurantCapital FMFormer Kiambu governor Waititu, co-accused plead for leniency in sentencingZDF heuteEntdecken Sie das ZDF-NachrichtenstudioGhaflaRuto Breaks Ground on KSh70 Billion Cement Plant in KituiBBC NewsUK and EU officials to discuss freedom of movement
The Daily Newsstand · Free, Always
Tuesday, October 27, 2026

Я раздаю root на своём сервере всем подряд — и он до сих пор жив

Translate

На prodisdown.ru любой посетитель за несколько секунд получает терминал в браузере. За ним — «сервер», на котором что-то сломано, и права sudo, чтобы это починить. Для первого кейса даже регистрация не нужна.

Всё это крутится на одном VPS с 3 vCPU и 6 ГБ памяти. Одновременно на нём живут до девяти таких окружений чужих людей.

Сразу честно: root у игрока — внутри песочницы, а не на самом хосте. Но песочница — это контейнер, а у контейнера с root есть много способов испортить жизнь хосту и соседям. Ниже — четыре слоя защиты, грабли, на которые я наступил по дороге, и сколько всё это стоит в железе.

Сам тренажёр я описывал в первой статье. Здесь — только про то, как раздавать shell незнакомцам и спать спокойно.

Что может сделать человек с root в контейнере

Я исходил из того, что каждый игрок рано или поздно наберёт :(){ :|:& };:. Не из злобы — просто интересно, что будет. А кто-то решит проверить, нельзя ли выбраться на хост.

Вот список, с которым я сел проектировать защиту:

Что делает игрок

Чем это опасно

Чем закрыто

Ищет дыру в ядре или в Docker

Побег на хост — и дальше уже всё

gVisor, cap_drop: ALL

Стучится в SSH сервера изнутри

Перебор паролей в обход внешнего фаервола

Правило ufw для подсети контейнеров

Качает майнер, шлёт спам, сканирует интернет

Абузы хостеру и бан IP

Сеть без выхода в интернет

Лезет в окружение соседа

Ломает чужую игру

Отдельная сеть на каждый запуск

while true на все ядра

Сайт тормозит у всех

Квота CPU и низкий приоритет

Форк-бомба

Кончаются процессы на хосте

pids_limit: 256

Съедает всю память

OOM-killer приходит за сайтом

512 МБ без swap

dd if=/dev/zero of=/big

Кончается диск, падает всё

Сторож диска

Открывает 50 вкладок

Занимает все места на сервере

Лимиты на игрока и IP, очередь

Подменяет проверку

Первое место в рейтинге за две команды

Скрипт проверки из репозитория и docker diff

Дальше по слоям, снизу вверх.

Слой 1: gVisor вместо обычного runc

Обычный Docker-контейнер делит ядро с хостом. Любая дыра в ядре, доступная из контейнера, означает root на сервере. Для своих сервисов это приемлемый риск, для sudo в руках незнакомцев — нет.

gVisor ставит между процессами контейнера и ядром своё ядро в user space — Sentry. Системные вызовы игрока обрабатывает он, а до настоящего ядра доходит узкий набор вызовов. Чтобы выбраться на хост, нужны уже две уязвимости подряд.

Подключается он как ещё один runtime в /etc/docker/daemon.json:

{
  "runtimes": {
    "runsc": {
      "path": "/usr/local/bin/runsc",
      "runtimeArgs": ["--allow-suid", "--overlay2=none"]
    }
  }
}

А в коде запуска — один параметр из переменной окружения. Если gVisor что-то сломает, откат — пустая строка в .env:

if SCENARIO_RUNTIME:
    run_kwargs["runtime"] = SCENARIO_RUNTIME   # "runsc" или пусто = runc

Оба флага нашлись не из документации, а из поломок:

  • --allow-suid. Без него gVisor игнорирует setuid-бит, и sudo перестаёт работать. В тренажёре по чинке серверов это конец игры.

  • --overlay2=none. По умолчанию изменения файлов остаются внутри песочницы. Тогда docker diff пустой, и сторож диска с защитой проверки (о них ниже) слепнут.

Поверх gVisor контейнер стартует с cap_drop: ALL. Обратно возвращаются только семь возможностей, без которых не работают sudo и сами кейсы: CHOWN, SETUID, SETGID, DAC_OVERRIDE, AUDIT_WRITE, KILL, SYS_PTRACE. Побочный эффект: без CAP_FOWNER root в контейнере может сделать chown чужого файла, но не chmod. В скриптах подготовки кейсов пришлось менять порядок: сначала chmod, потом chown.

Что сломалось под gVisor

gVisor — не Linux, а его тщательная имитация. В тренажёре, где кейсы строятся на тонкостях Linux, расхождения вылезли сразу:

Что сломалось

Почему

Как обошёл

dnsmasq в DNS-кейсе не стартует

gVisor поддерживает только группу netlink RTMGRP_LINK

Прослойка через LD_PRELOAD, 33 строки на C

В кейсе про иноды иноды не кончаются

tmpfs внутри gVisor игнорирует nr_inodes

tmpfs монтирует Docker на хосте анонимным томом

В кейсе про память oom_scoreвсегда 0

gVisor не считает его для процессов

Никак: на решение не влияет, живём с нулём

Прежде чем включить gVisor на проде, я прогнал все 39 вариантов 13 кейсов под обоими рантаймами. Сценарий теста: старт → проверка не проходит → команда из третьей подсказки → проверка проходит. Без такого прогона первые две строки таблицы нашли бы игроки.

Слой 2: сеть

Каждый запуск получает свою Docker-сеть с internal=True. У такой сети нет выхода в интернет, а контейнеров других игроков в ней нет. Майнеру не достучаться до пула, сканеру — до чужих машин, игроку — до соседа.

network = client.networks.create(
    name, driver="bridge", internal=True,
    labels={"role": "scenario-network", "scenario_id": scenario_id},
)
network.connect(WEB_CONTAINER)   # веб проксирует терминал игрока

Сеть удаляется вместе с контейнером, а сборщик мусора по метке подчищает брошенные. Это важно: свободных подсетей у Docker по умолчанию всего около 30.

На бумаге всё изолировано. На деле нашлись три дыры.

Дыра 1: сайт сидит в сети игрока. Терминал проксирует веб-приложение, поэтому оно подключено ко всем сетям запусков. Значит, из контейнера можно постучаться в gunicorn напрямую, мимо Caddy, и прислать любой X-Forwarded-For. А по IP у меня считаются лимиты на вход, регистрацию и запуск окружений. Стандартный ProxyFix верит заголовку от кого угодно, поэтому я обернул его: заголовок учитывается, только если соединение пришло с адреса Caddy или с localhost.

def __call__(self, environ, start_response):
    if self.is_trusted(environ.get("REMOTE_ADDR")):
        return self.via_proxy(environ, start_response)   # ProxyFix(x_for=1)
    return self.wsgi_app(environ, start_response)        # IP = адрес соединения

Дыра 2: хост виден из любой сети контейнера. У bridge-сети есть адрес на самом сервере, а sshd слушает все интерфейсы. Игрок может стучаться в SSH сервера изнутри, и внешний фаервол хостера этого не увидит. Вход по паролю у меня и так выключен, но стучаться туда вообще не должно быть возможно. Первое правило ufw запрещает 22/tcp из подсетей Docker.

Подвох в том, что подсети Docker — это не только 172.16.0.0/12. По умолчанию он раздаёт 15 сетей из 172.17–172.31, а дальше берёт 192.168.0.0/16. Правило только для 172.16.0.0/12 работает, пока окружений мало, и молча перестаёт работать при масштабировании. Надёжнее явно задать пулы в daemon.json и закрыть фаерволом именно их:

{
  "default-address-pools": [
    { "base": "172.20.0.0/14", "size": 24 }
  ]
}

Дыра 3: Docker ходит мимо ufw. Это классика, но напомню. Порт, опубликованный через ports: "5000:5000", Docker открывает своими правилами iptables раньше, чем до них дойдёт ufw. Поэтому наружу смотрит только Caddy на 80 и 443, а веб опубликован как 127.0.0.1:5000:5000. Контейнеры игроков не публикуют ничего.

Слой 3: ресурсы

Самая частая «атака» на таком сервисе — не взлом, а обычный while true. В моём случае его запускают даже не игроки: в двух кейсах процесс, съедающий CPU, — это и есть инцидент, который надо найти. Так что лимиты тут не только защита, но и часть игры.

Ресурс

Лимит на окружение

Зачем

CPU

cpu_quota: 1 ядро, в «прожорливых» кейсах — четверть ядра

Один игрок не заберёт весь сервер

Приоритет CPU

cpu_shares: 256 против 1024 у сайта

Когда процессора не хватает, сайт получает его первым

Память

512 МБ, swap запрещён

OOM-killer приходит в контейнер, а не за сайтом

Процессы

pids_limit: 256

Форк-бомба упирается в потолок

Логи контейнера

один файл на 5 МБ

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

Диск

1 ГБ записи, дальше контейнер удаляется

См. ниже

Время

10 минут без активности, 60 минут максимум

Забытая вкладка не держит место

Количество

2 на игрока, 2 на IP, 10 новых с IP за 10 минут

Скрипт со свежими cookie не займёт все места

Трюк с четвертью ядра: дашборд тренажёра показывает CPU относительно лимита контейнера. Игрок видит привычные 100%, top по-прежнему показывает виновника первым, а сервер платит за это вчетверо меньше, чем при лимите в целое ядро.

Диск: лимит, который молча не работает

У Docker есть --storage-opt size=1G. На обычном ext4 с overlayfs он ничего не ограничивает: ему нужен xfs с квотами. А один dd или fallocate в контейнере за минуту съедает весь диск — и встают база, Docker и сайт.

Переезжать на xfs ради одного флага я не стал и написал сторожа — фоновый поток в веб-приложении:

  1. Каждые 2 секунды он смотрит свободное место через statvfs — это почти бесплатно.

  2. Если место убыло на 256 МБ или прошло 30 секунд, пересчитывает размер контейнеров. Это дорого: Docker обходит их файлы.

  3. Контейнер, записавший больше 1 ГБ, удаляется. Сами кейсы пишут меньше мегабайта, так что ложных срабатываний нет.

  4. При свободных меньше 2 ГБ новые окружения не запускаются.

for info in client.api.containers(all=True, filters=SCENARIO_LABEL_FILTER, size=True):
    if (info.get("SizeRw") or 0) > CONTAINER_DISK_LIMIT:   # 1 ГБ
        stop_container(info["Id"])

SizeRw считает занятые блоки, поэтому ловит и fallocate, который ничего не пишет, а только резервирует место. И тут снова всплывает gVisor: без --overlay2=none записи не доходят до слоя контейнера, и сторож видит ноль.

Места кончаются не от атак, а от рекламы

Первая версия поднимала окружение, как только открывалась страница кейса. В день рекламы во ВКонтакте нулевой кейс открыли 148 раз — и ни разу не взяли инцидент в работу. Зато 14 запусков получили отказ: места заняли те, кто заглянул на секунду и ушёл.

Теперь так:

  • Окружение готовится после первого действия на странице или через 5 секунд, если вкладка видна.

  • Невзятое окружение живёт, пока страница опрашивает сервер. Закрыл вкладку — через 2 минуты место свободно.

  • Когда мест нет, сервер отвечает 503 {"queued": true, "position": N}, а страница показывает место в очереди. Очередь живёт в памяти процесса. Кто 20 секунд не спрашивал, из неё выпадает.

Слой 4: проверке нельзя верить ничему внутри

Когда игрок жмёт «Проверить», в его контейнере выполняется check.sh. Если он вернул 0 — кейс решён, очки идут в рейтинг. Но у игрока в этом контейнере sudo. Первая версия брала check.sh из самого контейнера, и решение любого кейса выглядело так:

echo 'exit 0' | sudo tee /path/to/check.sh

Хорошо, скрипт читаем из репозитория и передаём текстом. Но он всё равно выполняется внутри и зовёт systemctl, curl, grep. Каждую из этих программ игрок может подменить. Может дописать свою библиотеку в /etc/ld.so.preload. Может поправить параметры варианта, против которых сверяется проверка.

Итоговая схема из трёх шагов:

  1. Скрипт — из репозитория. Он уходит аргументом в /usr/bin/bash -c. Именно /usr/bin, а не /bin: ссылку /bin игрок мог бы подменить целиком.

  2. docker diff перед каждой проверкой. Если изменено что-то в /usr, /bin, /sbin, /lib, ld.so.preload, sudoers или в файлах-помощниках кейса, проверка не запускается. Сами кейсы туда не пишут, а честному решению там нечего делать.

  3. Отпечаток параметров. sha256 файлов варианта снимается, когда игрок берёт инцидент. Терминала у него в этот момент ещё нет. Перед проверкой отпечаток сверяется.

tampered = tampered_paths(container.diff(), scenario_id)
if tampered:
    return {"solved": False, "message": TAMPERED_MESSAGE.format(paths=...)}
shell, script = _check_script(scenario_id)          # из репозитория
result = container.exec_run([shell, "-c", script, "check.sh"])

Игрок видит честное сообщение: «С подменёнными программами проверке нельзя верить — сбрось кейс и реши его заново».

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

Чего это не ловит: фейковый сервис на Python, который отвечает «200 OK» вместо починенного nginx. От этого защищают только сами проверки, и чем больше они смотрят на конфиги и состояние системы, а не только на код ответа, тем дороже фейк. А человек, который написал правдоподобный фейк, проблему обычно уже понял.

Сколько это стоит в железе

Память оказалась не проблемой: окружение тратит около 70 МБ из выделенных 512. Упираюсь я в процессор. И до замера я об этом не знал: лимит на сервере стоял 6 окружений, а влезало три.

Как мерил

В админке есть кнопка «Технический перерыв»: новые игроки получают 503, а те, кто уже играет, спокойно доигрывают. На пустом сервере запускается тест внутри веб-приложения. Он добавляет окружения по одному, в той же смеси кейсов, что выбирают игроки, и на каждом шаге ждёт около 45 секунд. Останавливается на первом шаге, где случилось хотя бы одно:

  • свободной памяти (MemAvailable) меньше 20%;

  • swap вырос больше чем на 100 МБ;

  • процессор сервера занят больше чем на 85% по /proc/stat;

  • p95 ответа /healthz больше 500 мс;

  • какое-то из окружений умерло.

Две метрики, которые врут

В первой версии теста нагрузку на процессор я оценивал по тому, во сколько раз замедлился короткий бенчмарк на Python. Тест остановился на двух окружениях: однопоточный бенчмарк измерял везение в планировщике, а не запас сервера. Правильный ответ оказался скучным — доля занятого времени всех ядер из /proc/stat.

А вот две метрики, на которые хочется посмотреть сначала, и которым с десятком cpu_quota верить нельзя:

  • Load average. Потоки, которые упёрлись в квоту и ждут своего куска времени, считаются готовыми к работе. LA растёт, хотя процессор ещё свободен.

  • PSI (/proc/pressure/cpu). Доля some держалась на 80–97% уже при одном окружении. Причина та же: ожидание в квоте выглядит как нехватка CPU. Как порог — бесполезно.

Результаты

Сервер

Квота «прожорливых» кейсов

Окружений до 85% CPU

Память на окружение

p95 сайта

2 vCPU, 3,8 ГБ

0,5 ядра

3

~75 МБ

≤ 45 мс

3 vCPU, 6 ГБ

0,25 ядра

9

~70 МБ

≤ 31 мс

На старом сервере занятость CPU шла так: 31%, 61%, 74%, 90% на одном–четырёх окружениях. При этом по памяти тот же сервер спокойно держал 16 окружений без единого мегабайта в swap.

Самое интересное — цена одного окружения по кейсам, в процентах от трёх ядер:

Кейс

В чём инцидент

+CPU сервера

База данных не отвечает

Упал PostgreSQL

7–16%

OOM Killer убивает процессы

Утечка памяти

10%

Первое дежурство

Процесс съел CPU

8%

Высокая нагрузка на CPU

Воркеры съели CPU

7%

Диск заполнен на 100%

Закончилось место

3%

Самым дорогим оказался кейс, в котором нет никакого прожорливого процесса. Там API раз в секунду пытается переподключиться к упавшей базе, а дашборд раз в 3 секунды снимает метрики через docker exec. На тестовой машине один только сбор метрик стоил около 11% ядра, пока страницу смотрят. Наблюдение за инцидентом обходится дороже самого инцидента.

Итог: четверть ядра вместо половины для «прожорливых» кейсов и одно дополнительное ядро превратили 3 места в 9. Лимит на сервере теперь тоже 9, а десятый игрок встаёт в очередь.

Чек-лист: если тоже хотите раздавать shell

Всё выше сжато в список, который я хотел бы прочитать до первого игрока:

  • Контейнеры чужих людей — под gVisor, а не под голым runc. И все сценарии прогнаны под ним до включения.

  • cap_drop: ALL, обратно только то, без чего не работает sudo.

  • Отдельная internal-сеть на каждую сессию.

  • Если ваше приложение само сидит в этих сетях, оно не верит X-Forwarded-For оттуда.

  • SSH и другие сервисы хоста закрыты фаерволом от всех подсетей Docker. Пулы адресов заданы явно в daemon.json.

  • Никаких ports: "X:X" без 127.0.0.1: — Docker ходит мимо ufw.

  • Лимиты: cpu_quota и cpu_shares, mem_limit = memswap_limit, pids_limit, размер логов.

  • Диск контролирует сторож по SizeRw: --storage-opt size на ext4 молча не работает.

  • У каждого окружения есть таймаут простоя и жёсткий срок жизни.

  • Лимиты на игрока и на IP, а когда мест нет — очередь вместо ошибки.

  • Окружение поднимается, когда человек правда начал играть, а не при открытии страницы.

  • Проверка не доверяет ничему внутри: скрипт из репозитория, docker diff, отпечаток параметров.

  • Вместимость измерена на настоящем сервере по CPU из /proc/stat. Не по load average и не по PSI.

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

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.