
На jeopardy-CTF в прошлом месяце одна из задач выдала архив на 12 000 файлов — флаг лежал в единственном, закодированный в base64 внутри HTML-комментария. Ручной поиск — лотерея на несколько часов. Один grep -rIoE с правильной регуляркой нашёл кандидатов за 0.3 секунды, base64 -d через пайп декодировал флаг ещё за одну. После того кейса я собрал набор bash-обвязок, которые закрывают три главные рутины соревнований: перебор портов когда nmap недоступен, парсинг вывода сканеров и массовый поиск флагов по файловой системе. Ниже — готовые скрипты с построчным разбором и описанием граблей, на которые наступал сам.
Прежде чем копировать скрипты — две проверки, которые спасают от потери времени уже на соревновании.
Первая — убедиться что используется именно bash, а не dash или sh. В Debian и Ubuntu /bin/sh ссылается на dash, где /dev/tcp не существует и половина конструкций молча ломается. Проверка: bash --version. На macOS по умолчанию стоит Bash 3.2 — Apple не обновляет из-за лицензии GPLv3. Ассоциативные массивы, wait -n и ряд конструкций из скриптов ниже не заработают без brew install bash.
Вторая — создать рабочую директорию заранее: mkdir -p ~/ctf/scan && cd ~/ctf/scan. Все выходные файлы падают в одно место. Посреди соревнования искать результаты по всему диску — потеря раундов, которая стоит очков.
Минимальный набор: curl, grep, awk, sed есть в любом дистрибутиве. Для продвинутого парсинга полезен jq — ставится через apt install jq. Каждый скрипт начинается с шебанга #!/bin/bash и становится исполняемым после chmod +x script.sh. Без chmod +x — Permission denied, первая ловушка, через которую проходит каждый новичок.
Третье: каждый серьёзный скрипт длиннее трёх строк начинается с set -euo pipefail. Флаг -e прерывает выполнение при первой ошибке, -u ругается на неинициализированные переменные, -o pipefail прокидывает ненулевой код возврата через пайпы. Без этой тройки скрипт при сбое тихо продолжает работу и выдаёт мусорный результат, который вы примете за чистый вывод. Я на одном CTF потерял полчаса, потому что скрипт без -u молча подставлял пустую переменную в URL — и фаззил корень сайта вместо нужного пути.
[Применимо: CTF jeopardy, CTF attack-defense, внутренний пентест на Linux-инфраструктуре]
На CTF-площадках с ограничениями по инструментам nmap бывает недоступен. Bash умеет проверять TCP-порты через виртуальное устройство /dev/tcp — это не файл на диске, а встроенный механизм оболочки. Запись в /dev/tcp/host/port инициирует TCP-соединение: порт открыт — код возврата 0, закрыт — ошибка. В терминологии MITRE ATT&CK использование bash для сканирования маппится на Active Scanning (T1595, тактика Reconnaissance), а сам интерпретатор — Unix Shell (T1059.004, тактика Execution). Маппинг условен: ATT&CK описывает TTPs реальных adversary, а не учебные CTF-сценарии, но понимание классификации полезно для пентестерских отчётов.
Предусловия и ограничения:
- Работает если: bash скомпилирован без --disable-net-redirections (по умолчанию /dev/tcp включён в подавляющем большинстве сборок; отключён только в редких minimal/hardened дистрибутивах)
- Не работает если: используется dash или sh; bash собран без поддержки сетевых перенаправлений; цель за файрволом с DROP-политикой (таймаут вместо RST растягивает скан до бесконечности)
- /dev/tcp работает только с TCP — для UDP нужен nmap -sU или nc -u -w 1 $HOST $port
Базовый последовательный вариант — цикл for port in $(seq 1 1024) с попыткой echo >/dev/tcp/$HOST/$port в subshell. На 1024 порта с таймаутом ~1 секунда на каждый закрытый порт уходит до 17 минут. На CTF это неприемлемо. Решение — параллелизация:
#!/bin/bash
set -uo pipefail
HOST="${1:?Usage: $0 <target>}"; MAX_JOBS=50
scan_port() {
(echo >/dev/tcp/"$HOST"/"$1") 2>/dev/null && echo "[+] $HOST:$1 open"
}
for port in $(seq 1 1024); do
scan_port "$port" &
(( $(jobs -r | wc -l) >= MAX_JOBS )) && wait -n
done; wait
Разбор построчно. set -uo pipefail — строгий режим без -e, потому что ошибки на закрытых портах — ожидаемое поведение, и -e тут всё убьёт. MAX_JOBS=50 — потолок одновременных процессов: 1024 фоновых машина переживёт, 65535 — скорее всего нет, ядро упрётся в лимит на дескрипторы. Функция scan_port оборачивает проверку порта в subshell через скобки () — изолирует ошибку закрытого порта от основного процесса. Перенаправление 2>/dev/null подавляет сообщения Connection refused. Оператор && после subshell выполняет echo только при успешном соединении. Конструкция jobs -r | wc -l считает активные фоновые процессы, wait -n дожидается завершения любого одного из них, освобождая слот. Финальный wait без аргументов ждёт все оставшиеся.
Альтернатива через xargs — для тех, кто не хочет управлять очередью вручную: seq 1 1024 | xargs -P 50 -I{} bash -c '(echo >/dev/tcp/"$1"/{}) 2>/dev/null && echo "[+] $1:{} open"' _ "$HOST". Здесь -P 50 — те же 50 параллельных процессов, xargs сам рулит очередью.
Когда техника НЕ работает:
Баннер-граббинг через /dev/tcp ненадёжен — для определения версий сервисов нужен nmap -sV или ручной nc -w 2 $HOST $port. При DROP-политике файрвола каждый закрытый порт висит до системного таймаута (~30 секунд), что ломает параллельную стратегию. Обходится через timeout 1 bash -c "echo >/dev/tcp/$HOST/$port", но каждый timeout порождает дополнительный процесс.
Сканирование портов — категория Port Scan (#14 по классификации AbuseIPDB), стандартный триггер для автоматических блокировок. На CTF это обычно не проблема, на реальном пентесте — учитывайте: Sigma-правило lnx_shell_susp_commands.yml ловит подозрительное выполнение shell-команд, а счётчик AbuseIPDB может повысить confidence score адреса выше порога блокирования (рекомендуемый cutoff — 75 из 100).
Основная задача на CTF — не запустить сканер, а быстро вытащить из его вывода нужное. Большинство гайдов заканчиваются на grep "open" — это покрывает от силы десятую часть реальных задач парсинга.
Ключ -oG (grepable output) выдаёт одну строку на хост со всеми открытыми портами — формат создан специально для конвейерной обработки. Стандартный многострочный вывод nmap парсить неудобно: каждый порт на отдельной строке, между хостами пустые строки и декоративные рамки. Запуск: nmap -sC -sV -oG scan.grep -oN scan.txt 10.10.0.0/24. Два формата одновременно — привычка, которая экономит время. Grepable для скриптов, normal для глаз.
Извлечь IP с открытым портом 80: grep '80/open' scan.grep | awk '{print $2}'. Здесь awk '{print $2}' выводит второе поле строки — IP-адрес, первое поле в формате -oG это слово Host:.
Получить список портов конкретного хоста: grep '10.10.0.5' scan.grep | grep -oE '[0-9]+/open' | cut -d'/' -f1. Регулярка [0-9]+/open через grep -oE (расширенные регулярки) извлекает номер порта перед /open, cut -d'/' -f1 отрезает всё после слэша. Я использую -oE вместо -oP, потому что BSD grep на macOS не поддерживает Perl-совместимые регулярки — -oE работает везде.
Скрипт для массовой обработки grepable-вывода:
#!/bin/bash
# Парсит nmap -oG -> ip: список портов
while read -r line; do
ip=$(echo "$line" | awk '{print $2}')
ports=$(echo "$line" | grep -oE '[0-9]+/open' \
| cut -d'/' -f1 | tr '\n' ',')
[ -n "$ports" ] && echo "$ip -> ${ports%,}"
done < <(grep '/open/' "$1")
Разбор: while read -r line читает строки целиком, флаг -r отключает интерпретацию обратных слэшей — без него строка с путями вроде /etc/passwd потеряет слэши. Process substitution < <(grep ...) передаёт в цикл только строки с открытыми портами, пропуская комментарии и пустые. tr '\n' ',' склеивает порты через запятую. Конструкция ${ports%,} убирает завершающую запятую через pattern removal — без неё вывод заканчивается на 22,80,443, с висящим хвостом.
Ещё полезно: вывести все хосты с конкретным сервисом. grep -i 'ssh' scan.grep | awk '{print $2}' вернёт IP всех машин, где nmap нашёл SSH. Объединение с сортировкой: grep '/open/' scan.grep | awk '{print $2}' | sort -u даёт дедуплицированный список всех живых хостов.
На CTF часто нужно обработать десятки HTTP-ответов: найти страницу с отличающейся длиной (подсказка к уязвимости), вытащить токен из заголовка, отфильтровать по статусу.
Базовый паттерн — curl -s -o /dev/null -w "%{http_code} %{size_download}" — возвращает HTTP-код и размер ответа в байтах без тела. Обернув в цикл с вордлистом: while read -r dir; do code=$(curl -s -o /dev/null -w "%{http_code}" "http://target/$dir"); [ "$code" != "404" ] && echo "$code $dir"; done < wordlist.txt. Простой directory fuzzer из одной строки. Для серьёзного фаззинга лучше ffuf или gobuster, но когда их нет — цикл с curl спасает.
Критичная ошибка новичков — пробелы в [ "$code" != "404" ]. Квадратные скобки — вызов команды test. Пробелы вокруг скобок и оператора обязательны. Запись ["$code" без пробела — [: command not found. Самый частый вопрос от начинающих и самый раздражающий синтаксический нюанс Bash. Каждый через это проходит, и каждый раз хочется стукнуть по столу.
Для JSON-ответов незаменим jq: curl -s http://target/api/user | jq -r '.token'. Без jq — через grep -oE '"token":"[^"]+"' | cut -d'"' -f4, но конструкция хрупкая: ломается на вложенных кавычках или нестандартных пробелах в JSON. Для извлечения заголовков: curl -sI http://target | grep -i 'set-cookie' — флаг -I отправляет HEAD-запрос и возвращает только заголовки.
Несколько паттернов sed, которые на CTF пригождаются постоянно. Удаление пустых строк: sed '/^$/d' output.txt. Извлечение значения после password=: sed -n 's/.*password[=:][[:space:]]*\([^ ]*\).*/\1/p' config.txt. Вывод строк с 10 по 20 из лога: sed -n '10,20p' access.log. Разбивка строки по разделителю: echo "hash:salt:user" | sed 's/:/\n/g' — три отдельные строки.
В терминологии MITRE ATT&CK это пересечение File and Directory Discovery (T1083, тактика Discovery) и Unix Shell (T1059.004) — фреймворк описывает TTPs реальных атакующих, а не CTF-задачи, но знание маппинга полезно для пентестерских отчётов. На CTF целевые данные — флаги.
Флаги на разных платформах используют предсказуемые форматы. По данным Capture The Flag Cheatsheet, типичные паттерны: flag{...}, FLAG{...}, CTF{...}, HTB{...}, THM{...}. Типичные места хранения: /root/root.txt, домашние директории пользователей, конфиги, переменные окружения (через env), заголовки и куки HTTP-ответов, дампы баз данных. Зная форматы и локации, можно написать универсальный поисковик:
#!/bin/bash
PATTERN='(flag|FLAG|CTF|HTB|THM|codeby)\{[A-Za-z0-9_\-]+\}'
TARGET="${1:-.}"
echo "[*] Текстовые файлы:"
grep -rIoE "$PATTERN" "$TARGET" 2>/dev/null | head -50
echo "[*] Бинарники (через strings):"
find "$TARGET" -type f -exec strings {} + 2>/dev/null \
| grep -oE "$PATTERN" | head -20
echo "[*] Переменные окружения:"
env | grep -oE "$PATTERN"
Разбор. PATTERN хранит regex для шести CTF-форматов через | (альтернация в ERE). Фигурные скобки экранированы \{ и \}, потому что в расширенных регулярках { — квантификатор. Внутри скобок [A-Za-z0-9_\-] — допустимые символы тела флага. Конструкция ${1:-.} использует первый аргумент или текущую директорию как дефолт — удобно при ленивом вызове без параметров. Флаг -r делает grep рекурсивным, -I пропускает бинарные файлы (иначе grep подавится и выплюнет мусор), -o выводит только совпадение, -E включает расширенные регулярки. head -50 ограничивает вывод — на CTF-машине с тысячами файлов без лимита терминал улетит в бесконечный скролл.
Блок с find ... -exec strings {} + обрабатывает бинарные файлы: утилита strings извлекает последовательности печатных символов, а grep фильтрует по паттерну. Именно через strings чаще всего находят флаги в скомпилированных бинарниках и ELF-файлах — стандартный подход из чит-шитов HackTheBox и TryHackMe: strings /path/to/binary | grep -E 'flag|CTF|HTB'.
Предусловия и ограничения:
- Работает если: есть read-доступ к целевым директориям, GNU grep установлен
- Не работает если: файлы в кодировке UTF-16 — grep не увидит ASCII-строки без предварительной iconv -f UTF-16 -t UTF-8
- Не работает если: флаг зашифрован, обфусцирован или разбит по частям в разных файлах (и тогда начинается совсем другая история)
Base64-обёрнутые флаги — частый приём на jeopardy. Подход: сначала найти строки, похожие на base64 (20+ символов из base64-алфавита с возможными = в конце), затем прогнать каждую через декодер. В одну строку: grep -rIoE '[A-Za-z0-9+/]{20,}={0,2}' "$TARGET" 2>/dev/null | while read -r b; do echo "$b" | base64 -d 2>/dev/null | grep -oE "$PATTERN"; done. Первый grep находит кандидатов, цикл декодирует и фильтрует.
Hex-кодированные флаги: grep -rIoE '([0-9a-fA-F]{2}){10,}' "$TARGET" 2>/dev/null | while read -r h; do echo "$h" | xxd -r -p 2>/dev/null | grep -oE "$PATTERN"; done. Здесь xxd -r -p конвертирует hex в бинарные данные, grep ищет флаг в результате.
Для поиска в HTTP-ответах: curl -s http://target/ | grep -oE '<!--.*-->' | grep -oE "$PATTERN" — извлекает HTML-комментарии и ищет в них флаг. Вариант для заголовков: curl -sI http://target/ | grep -oE "$PATTERN" — флаг может лежать в кастомном заголовке. На одном CTF я нашёл флаг в заголовке X-Secret-Flag — задача была рассчитана на тех, кто не смотрит заголовки ответа. Ну и далее в том-же духе..
Однострочники — основной инструмент на соревнованиях, когда на полноценный скрипт нет времени. Ниже — те, которые я использую на каждом CTF.
Массовый пинг подсети: for i in $(seq 1 254); do ping -c 1 -W 1 10.10.0.$i &>/dev/null && echo "10.10.0.$i alive" & done; wait. -c 1 — один ICMP-пакет, -W 1 — таймаут секунда. Амперсанд & параллелит проверку всех хостов, wait ждёт завершения.
Извлечение уникальных IP из лога: grep -oE '([0-9]{1,3}\.){3}[0-9]{1,3}' access.log | sort -u. Регулярка ([0-9]{1,3}\.){3}[0-9]{1,3} матчит IPv4-адреса, sort -u убирает дубликаты.
Быстрый перебор поддоменов: while read -r sub; do host "$sub.target.com" | grep -q "has address" && echo "$sub.target.com"; done < subdomains.txt. Команда host делает DNS-запрос, grep -q молча проверяет наличие ответа.
Декодирование всех base64-строк из файла: grep -oE '[A-Za-z0-9+/]{4,}={0,2}' dump.txt | while read -r b; do decoded=$(echo "$b" | base64 -d 2>/dev/null) && echo "$decoded"; done. Полезно на jeopardy при анализе дампов.
Мониторинг изменений на веб-странице (attack-defense): while true; do curl -s http://target/page | md5sum; sleep 5; done. Хеш пересчитывается каждые 5 секунд — как только изменился, кто-то патчил сервис или эксплуатировал уязвимость.
Массовая проверка HTTP-статусов из списка URL: while read -r url; do echo "$(curl -s -o /dev/null -w '%{http_code}' "$url") $url"; done < urls.txt. Результат — таблица 200 http://target/admin, 403 http://target/backup, 301 http://target/old.
Поиск SUID-бинарников для эскалации привилегий: find / -perm -4000 -type f 2>/dev/null. Стандартный первый шаг — найти бинарники с SUID-битом и проверить их через GTFOBins (gtfobins.github.io). Это тоже техника Unix Shell (T1059.004) — Atomic Red Team включает тест «Harvest SUID executable files» именно с такой командой.
Большинство багов в CTF-скриптах — не в логике, а в особенностях синтаксиса bash. Три категории ошибок ломают скрипты чаще всего.
Кавычки и word splitting. Переменная без двойных кавычек проходит через word splitting и globbing. Файл называется flag file.txt. Команда cat $file (без кавычек) интерпретирует пробел как разделитель: bash видит cat flag и file.txt — два несуществующих файла. Правильно: cat "$file". Правило — всегда оборачивать переменные в двойные кавычки, кроме случаев когда word splitting нужен намеренно, например при итерации for port in $ports_list.
IFS и нестандартные разделители. Переменная IFS (Internal Field Separator) определяет, по каким символам bash разбивает строки на слова. По умолчанию — пробел, табуляция, перевод строки. Если парсите CSV или вывод с нестандартным разделителем, переопределяйте IFS локально: IFS=':' read -r user pass hash <<< "$line". Эта конструкция разобьёт строку admin:password123:md5hash на три переменные. Глобальное изменение IFS без восстановления — причина необъяснимых багов, когда скрипт нормально работает на одних данных и ломается на других. Всегда восстанавливайте: OLD_IFS="$IFS"; IFS=','; ...; IFS="$OLD_IFS", или используйте IFS только в контексте read. Я однажды убил полчаса на дебаг скрипта, который ломался на третьем проходе цикла — оказалось, IFS перезаписался в первой итерации и всё пошло наперекосяк.
Race conditions при параллельной записи. Когда несколько фоновых процессов пишут в один файл через >>, записи могут перемешаться. Два процесса одновременно дописывают результат в results.txt — получаете строку [+] 10[+] 10.10.0.5:80 open.10.0.3:22 open, где два вывода склеились в кашу. Решения два: либо flock -x results.lock bash -c "echo '[+] $HOST:$port open' >> results.txt" для эксклюзивной блокировки, либо собирать вывод через stdout — каждый фоновый процесс пишет в stdout, wait дожидается всех, а перенаправление всего блока собирает результат после завершения.
Проверка аргументов. Конструкция [ -z "${1:-}" ] && echo "Usage: $0 <target>" && exit 1 проверяет наличие первого аргумента. Запись ${1:-} возвращает пустую строку если $1 не задан — без этого при set -u скрипт упадёт с ошибкой unbound variable ещё до вывода подсказки.
На CTF один и тот же набор действий повторяется из задачи в задачу. Вместо копирования однострочников — вынесите рутину в функции и загрузите через source ~/ctf/functions.sh.
Функция быстрого скана: qscan() { nmap -sC -sV -oG "$HOME/ctf/$1.grep" -oN "$HOME/ctf/$1.txt" "$1"; }. После загрузки через source вызываете qscan 10.10.0.5 — результаты автоматически сохраняются с именем цели, не нужно каждый раз вспоминать ключи и пути.
Функция поиска флагов: fflag() { grep -rIoE '(flag|FLAG|CTF|HTB|THM)\{[A-Za-z0-9_-]+\}' "${1:-.}" 2>/dev/null; }. Вызов: fflag /tmp/challenge — одно слово вместо строки с регуляркой.
Функция извлечения портов из grepable-файла: ports() { grep '/open/' "$1" | grep -oE '[0-9]+/open' | cut -d'/' -f1 | sort -n | tr '\n' ',' | sed 's/,$//'; }. Результат вида 22,80,443 — готовый аргумент для nmap -p $(ports scan.grep) target, когда нужно повторно просканировать только открытые порты с другими ключами.
Функция проверки живых хостов: alive() { for i in $(seq 1 254); do (ping -c1 -W1 "$1.$i" &>/dev/null && echo "$1.$i") & done; wait; }. Вызов: alive 10.10.0 — список живых хостов в подсети.
Загрузка при старте сессии: добавьте source ~/ctf/functions.sh в ~/.bashrc. Каждый раз при открытии терминала функции доступны без ручного импорта. На Attack-Defense CTF это экономит первые критические минуты раунда — пока другие вспоминают ключи nmap, вы уже запустили qscan и fflag.
Согласно исследованию на arxiv, GPT-4o решает 80% задач beginner-уровня OverTheWire Bandit — но проваливается на сценариях с несколькими последовательными командами и необходимостью сохранять рабочую директорию. LLM справляется с отдельными командами, но не может собрать из них рабочий пайплайн. Именно сборка пайплайнов отличает опытного CTF-игрока от новичка, и ради этого навыка стоит разбираться в bash-скриптинге, а не копировать команды из ChatGPT.
Мой опыт за два года: участники, которые приходят на CTF с готовым functions.sh и set -euo pipefail в каждом скрипте, стабильно быстрее тех, кто каждый раз пишет с нуля. Не потому что их скрипты лучше — а потому что они не тратят время на отладку опечаток и молчаливых сбоев. Разница между 20 минутами на разведку и 5 минутами — это разница между флагом и пустыми руками. Bash не заменяет Python для сложной логики и крипто, но 80% рутины на соревнованиях — сканирование, парсинг и поиск по шаблону. Пять-шесть конструкций, которые укладываются в одну страницу. Проблема не в инструменте, а в дисциплине: комментировать скрипты, хранить в одном месте, не забывать про кавычки и IFS. Если хочешь не просто собирать однострочники, а пройти всю цепочку от разведки до пост-эксплуатации с лабами и ментором — WAPT покрывает эту прогрессию модуль за модулем.
🚀 Хочешь закрепить на практике? Реши задачи по теме на HackerLab — категория «pentest-machines».
0 комментариев
Пожалуйста, войдите, чтобы оставить комментарий.
Загрузка комментариев...