Статья Балансировка сложности CTF заданий: от кривой difficulty до борьбы с unintended solutions

Пять плат на алюминиевых подставках разной высоты образуют ступенчатую кривую сложности. Тёплый янтарный свет лампы и холодное голубое свечение LED подчёркивают атмосферу технической лаборатории.


На региональном студенческом CTF, который команда HackerLab готовила в прошлом году, из 28 тасков семь не решила ни одна команда за 48 часов, а пять заданий сдали все участники в первые 40 минут. Лидерборд замер к середине соревнования: команды выбили easy-таски и упёрлись в стену hard-задач без промежуточного уровня. Post-mortem показал банальное - провал балансировки сложности CTF заданий и отсутствие нормального плейтестинга. Не тематика, не квалификация участников. Просто кривая difficulty с дырой посередине. Ниже - конкретные правила проектирования difficulty curve, чеклист тестирования и разбор ошибок, которые я видел (и допускал сам).

Кривая сложности CTF: проектирование difficulty distribution​

Кривая сложности CTF - то, что отличает продуманный турнир от случайного набора тасков. Задания должны распределяться так, чтобы у каждого уровня участников был ощутимый прогресс на протяжении всего соревнования, а не резкий стоп после первого часа.

Пропорции по уровням​

Для jeopardy-формата на 8–12 часов с аудиторией от новичков до середнячков работает такое распределение:

УровеньПроцент решающих командДоля тасковПримеры
Easy70–90%25–30%Base64-декодирование, простой reflected XSS, ROT13, strings на бинарнике
Medium30–60%40–50%SQL injection с фильтрацией, RSA с общим модулем, ret2libc
Hard5–15%20–30%Heap exploitation, кастомный криптопротокол, kernel pwn

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

Для CTF с профессиональной аудиторией (CTFtime-рейтинг команд 50+) пропорция сдвигается: easy - 15%, medium - 35%, hard - 50%. Но и тогда easy остаётся: он верифицирует работоспособность VPN, submit-формы и формат флагов. Без этого вы узнаете о сломанном VPN через тикеты, а не через solve-лог.

Категориальный баланс​

Распределение по категориям (web, crypto, pwn, reversing, forensics, misc) определяется аудиторией. Для студенческих CTF - больше web и crypto, порог входа ниже. Для профессиональных - ровное распределение с акцентом на pwn и reversing, которые дают максимальное разделение по навыку.

Правило, к которому я пришёл после нескольких провалов: в каждой категории - минимум одно easy-задание. Три pwn-таска и все hard? Категория существует для трёх-четырёх команд. Остальные её игнорируют - пустая трата ресурсов на подготовку.

Исключение - misc. Там допустимо иметь только medium/hard, потому что misc по определению непредсказуем и участники не рассчитывают на стабильные решения.

Динамический скоринг и формула decay​

Статическое начисление баллов (easy = 100, medium = 200, hard = 500) плохо работает на практике. Задание, оценённое как medium при создании, может оказаться easy - и тогда оно приносит незаслуженно много очков решившим его первыми, перекашивая лидерборд.

Динамический скоринг (decay) решает проблему: чем больше команд решило задание, тем меньше баллов оно приносит. CTFd поддерживает это нативно через DynamicValueChallenge. Difficulty curve CTF соревнования при decay выравнивается автоматически:
Код:
score = max(minimum, initial - decay * (solves - 1))
Здесь initial - стартовые баллы (обычно 500), decay - коэффициент снижения за каждый solve, minimum - нижний порог (50–100). При decay=20 и initial=500 задание с 20 решениями стоит 120 баллов.

Типичные настройки для балансировки CTF турнира на 30–50 команд: initial=500, minimum=50, decay=15–25. Для крупных CTF (100+ команд) decay увеличивается до 30–40 - иначе easy-таски обесцениваются слишком быстро и первые решившие получают копейки.

Но decay не заменяет ручную балансировку. Он компенсирует ошибки оценки сложности. Если все задания одного уровня, decay просто уравняет их стоимость, но кривую прогрессии не создаст.

Тестирование CTF тасков: плейтестинг по чеклисту​

Плейтестинг CTF - единственная страховка от провала в бою. Красиво спроектированный таск, который разваливается при первом нестандартном вводе, хуже банальной задачи на SQL injection, работающей стабильно 48 часов подряд.

Требования к тестовому окружению​

Перед плейтестингом готовим:
  • Чистая VM или контейнер без инструментов разработки - имитация окружения участника (Kali Linux или Ubuntu с базовым набором: pwntools, Burp Suite Community, Ghidra, CyberChef)
  • Тестовый аккаунт в CTFd (или rCTF) - проверка submit флагов, работоспособности динамического скоринга
  • Сетевой доступ, идентичный боевому - те же порты, firewall-правила, VPN-конфигурация
  • Два независимых плейтестера, которые не являются авторами тестируемого задания

Три уровня тестирования​

Авторский прогон. Автор пишет solve-скрипт (intended solution) и проверяет, что задание решается за разумное время в чистом окружении. Типичная ошибка - тестировать на машине разработки, где стоят все зависимости и переменные окружения. Solve-скрипт запускается в чистом контейнере с тем инструментарием, который будет у участников. На своей машине всё всегда работает.

Перекрёстный плейтест. Другой член команды организаторов решает задание без подсказок. Если easy решается больше часа - это не easy. Если medium падает за пять минут - пересмотрите оценку. Перекрёстный плейтест - единственный способ проверить, что описание задания содержит достаточно информации и не допускает ложных интерпретаций.

«Тупой» прогон. Самый ценный уровень для поиска unintended solutions CTF. Плейтестер намеренно игнорирует intended path и пробует очевидное: strings на бинарнике, grep -r flag в файловой системе, прямое обращение к портам контейнера через nmap, SQL injection в каждое поле ввода, bruteforce коротких значений. У меня для этого отдельный чеклист типовых шорткатов, который растёт после каждого проведённого CTF.

Чеклист плейтестинга​

Каждый таск перед деплоем проходит десять контрольных точек:
  1. Solve-скрипт работает в чистом окружении - не на машине автора, а в контейнере без лишних зависимостей
  2. Описание самодостаточно - плейтестер без знания intended solution понимает задачу по тексту и приложенным файлам
  3. Флаг нельзя получить без решения - проверяем: strings, grep, файловая система контейнера, открытые порты (nmap -sV снаружи), прямой доступ к БД
  4. Инфраструктура изолирована - участник не видит чужие попытки, не имеет доступа к хост-системе, не интерферирует с другими командами
  5. Rate limiting настроен - bruteforce 32-символьного hex-флага нереалистичен по времени. А вот 4-символьный PIN перебирается за секунды - проверьте пространство
  6. Задание не гессится - решение определяется техническим навыком, а не угадыванием намерений автора
  7. Время решения адекватно - easy: 15–45 минут, medium: 1–3 часа, hard: 3–8 часов (для целевой аудитории)
  8. Параллельные подключения стабильны - 10+ команд одновременно работают с сервисом без деградации, race condition между командами исключён
  9. Нет утечек через метаданные - EXIF в картинках, комментарии в исходниках, debug-информация в бинарниках не содержат случайных подсказок или частей флага
  10. Подсказки заготовлены - если задание окажется слишком сложным, hints с понятным штрафом в баллах уже написаны и загружены в CTFd
Если хотя бы один пункт не пройден - таск не деплоится. Без исключений. Баг, обнаруженный на соревновании, стоит в десять раз дороже бага, пойманного при плейтестинге. Я это выучил на собственном опыте, когда на третьем часу CTF пришлось экстренно чинить таск при 80 подключённых командах.

Unintended solutions в CTF: анатомия и профилактика​

Unintended solution - когда участник решает задание не тем путём, который задумал автор. Иногда это красиво (элегантный shortcut через неочевидную уязвимость), иногда - катастрофа (таск обесценивается за минуты, лидерборд перекашивает). Как создать CTF задание, защищённое от unintended - разберём ниже.

Реальный пример: NaN ломает банк​

📚 Часть контента скрыта. Этот материал доступен участникам сообщества с рангом One Level или выше
Получить доступ просто — достаточно зарегистрироваться и проявить активность на форуме

Флаг получен без эксплуатации float-точности. Когда Дулин спросил ученика, как он нашёл решение, тот ответил: «I read the docs» - с открытой страницей man atof. Ключевые ошибки проектирования: непроверенная обработка нестандартного ввода (atof принимает NaN и Inf, а не только числа) и fail-open guard-условия. Классика.

Типы unintended solutions​

По опыту организации CTF, unintended solutions группируются в четыре категории:

Shortcut через некорректную изоляцию. Участник находит флаг в переменных окружения контейнера, через открытый порт БД или в метаданных файла. Задание даже не начали решать по существу. Аналогичный случай описан в публикации HackerLab: на внутреннем хакатоне участники подключились к MySQL напрямую через открытый порт в docker-compose, потому что пароль root'а остался дефолтным. Обидно, но предсказуемо.

Обход логики через нестандартный ввод. Пример с NaN выше - эталон этой категории. Сюда же: отрицательные числа, null-байты, Unicode BOM, строки длиной 10000+ символов. Особенно опасно для C/C++ тасков с ручным парсингом.

Решение «не той» уязвимостью. В web-таске заложен SSRF, а участник находит SQL injection в соседнем эндпоинте. Если уязвимость реалистичная и требует навыка - засчитать допустимо. Если появилась случайно - баг. Тут тонкая грань, и я обычно склоняюсь к тому, чтобы засчитывать - если человек нашёл реальную дыру, это навык.

Bruteforce вместо криптоанализа. Пространство ключей оказалось малым для перебора. Типичная ошибка в crypto: автор использует 16-битный ключ «для простоты», участник brute-force'ит за секунды вместо intended атаки (например, атаки Хастада на RSA с малой экспонентой). Минимальное пространство ключей, где bruteforce не является intended решением - порядка 2^40 и выше.

Профилактика unintended solutions​

Два независимых плейтестера - один проходит intended path, второй целенаправленно ищет шорткаты. Второму не рассказывают intended solution. Это принципиально: знание ответа искажает восприятие.

Fuzzing точек ввода. Прогоните все поля через edge cases: NaN, Inf, -0, пустая строка, \x00, максимальное значение типа, отрицательные числа. Для web-тасков: длинные строки, спецсимволы (', ", %00, ../). Занимает 20 минут и ловит половину потенциальных unintended.

Минимальные привилегии в контейнере. Непривилегированный пользователь, readonly filesystem, явный whitelist сетевых портов наружу. После деплоя - обязательный nmap -sV снаружи на IP контейнера. Открытый порт PostgreSQL при работающем web-таске - мгновенный unintended. Я видел это трижды.

Декомпозиция многоэтапных заданий. Если таск состоит из трёх шагов, убедитесь, что шаг 2 нельзя обойти без прохождения шага 1. Промежуточные данные (токен, cookie, ключ) не должны быть угадываемы или доступны через альтернативный путь.

Гессинг в CTF заданиях: как не превратить таск в лотерею​

Гессинг - когда для решения нужно угадать замысел автора, а не применить технический навык. Это главная претензия участников к плохим CTF, и CTF challenge design должен целенаправленно исключать такие ситуации.

Типичные проявления гессинга​

  • «Угадай инструмент» - задание на стеганографию без подсказки о методе. Участник перебирает StegHide, zsteg, OpenStego, Audacity - это не навык, а лотерея
  • Скрытая директория без словаря - web-задание, где нужно угадать /s3cr3t_p4n3l/ без возможности перебора по стандартному wordlist
  • Нестандартный шифр без контекста - crypto с кастомным алгоритмом, природу которого невозможно определить по выходным данным
  • Описание с двойным дном - текст допускает три интерпретации, две ведут в тупик, а третья очевидна только автору

Антигессинг-паттерны для авторов​

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

Явный формат флага. Укажите формат (flag{...}, CTF{...}, ugra_...) в правилах соревнования и в описании каждого задания. Участник, получивший результат, должен однозначно понимать - это флаг или промежуточные данные. Неочевидный формат - источник фрустрации, а не сложности.

Однозначность описания. Дайте текст задания двум людям без контекста. Если они поняли задачу по-разному - перепишите. Многозначное описание - это не «хитрая подсказка», а баг в дизайне.

Независимость тасков. Если для решения задания B нужны данные из задания A, укажите это явно. Скрытые зависимости между заданиями - чистый гессинг: участник может потратить три часа, не подозревая, что ему нужен артефакт из другого таска.

Правило одного поискового запроса​

Полезный критерий проверки на гессинг: если участник, определив тип задачи, может за один-два целенаправленных запроса найти метод решения - это не гессинг. Реализация метода по-прежнему требует навыка: написать скрипт, адаптировать exploit, понять контекст. Но если для определения самого метода нужно гуглить наугад, пробуя десятки инструментов - задание нуждается в доработке.

Критерий особенно полезен для crypto и forensics, где набор методов широк. Подсказка в описании должна сужать пространство поиска до конкретного класса атак или инструментов - не раскрывать ответ, но дать вектор. На бумаге формула понятна, но по-настоящему ощутить разницу между «задача на навык» и «задача на угадывание» можно только через практику решения чужих тасков - на HackerLab.pro, например, есть категории crypto и web с заданиями разного уровня, где хорошо видно, какие формулировки работают, а какие вызывают фрустрацию.

Балансировка сложности CTF заданий - процесс итеративный, и ни одна формула decay с чеклистом не заменят разбор данных после соревнования. Каждый CTF, который я провожу, заканчивается анализом solve-логов: кто решил задание не тем способом, кто застрял и почему, где кривая difficulty дала сбой. Эти данные ценнее теории - они показывают реальный разрыв между моим представлением о сложности и тем, как задачу воспринимают участники. Большинство challenge author'ов не делают post-mortem вообще: таск отработал, CTF закончился, writeup'ы участников никто не читает. Те же ошибки воспроизводятся на следующем турнире. Самое неприятное открытие за три года организации: мои оценки сложности ошибаются примерно в четырёх случаях из десяти. Easy оказывается medium, medium - hard, а задание, которое я считал hard, решает первокурсник за час через баг, не пойманный при плейтестинге. Decay-скоринг компенсирует часть ошибок, но только часть. Всё остальное - плейтестинг, post-mortem и готовность признать, что автор видит свой таск принципиально иначе, чем участник.
 
Последнее редактирование:
Мы в соцсетях:

Взломай свой первый сервер и прокачай скилл — Начни игру на HackerLab

Похожие темы

🚀 Первый раз на Codeby?
Гайд для новичков: что делать в первые 15 минут, ключевые разделы, правила
Начать здесь →
🔴 Свежие CVE, 0-day и инциденты
То, о чём ChatGPT ещё не знает — обсуждаем в реальном времени
Threat Intel →
💼 Вакансии и заказы в ИБ
Pentest, SOC, DevSecOps, bug bounty — работа и проекты от проверенных компаний
Карьера в ИБ →

HackerLab