MAATRIX / Блог / Скрипт из crontab ходит на внешний адрес: как понять, что он туда шлёт

Скрипт из crontab ходит на внешний адрес: как понять, что он туда шлёт

MAATRIX

Вы просматривали ss -tnp по другому поводу и заметили: раз в несколько минут один и тот же процесс, запущенный из cron, открывает соединение на внешний IP. Не к вашему API, не к известному провайдеру бэкапов — к адресу, который ничего не говорит ни вам, ни остальной команде. Задача может быть безобидной интеграцией, о которой просто забыли рассказать при передаче сервера. А может — вынесенным вовне механизмом кражи данных или закреплением атакующего. Разница между этими двумя сценариями не видна из одной строки crontab, но видна из того, что процесс реально отправляет в сеть. Разберём, как это узнать: перехватить трафик конкретной задачи, прочитать, что уходит в сокет, и определить, куда именно уходит адрес назначения. Это узкий разбор одной подозрительной задачи, а не полная инвентаризация cron — если вы ещё не прошли этап поиска всех задач по всем пользователям и systemd timers, начните с общей статьи про чужие cron-задачи на принятом сервере: там про то, как найти все строки расписания и разложить их по категориям риска. Здесь предполагается, что подозрительная строка уже найдена, и вопрос ровно один — что конкретно она шлёт наружу.

Сначала — не общий аудит, а один процесс: сузить до PID и содержимого

Прежде чем разворачивать перехват трафика, посмотрите на сам скрипт статически — часто ответ находится за минуту, без единого пакета захвата.

cat /opt/scripts/подозрительный_скрипт.sh
grep -nE 'curl|wget|nc |ncat|/dev/tcp|python.*requests|socket\.' /opt/scripts/подозрительный_скрипт.sh

Если в коде открыто виден curl -X POST https://api.известный-сервис.com/webhook — вопрос закрыт, это документируемая интеграция, дальше нужно только подтвердить у команды, что её действительно кто-то настраивал. Проблема начинается там, где скрипт неинформативен: адрес собирается из переменных, идёт через base64 -d, вызывает бинарник без исходников или является скомпилированным файлом без доступного кода. В этом случае статический анализ бессилен, и нужен перехват в момент реального выполнения.

Большинство cron-задач выполняются секунды, а расписание может быть нечастым — ловить момент запуска вручную по часам ненадёжно. Рабочий приём — временно подменить команду в crontab на враппер, который сам поднимает захват трафика вокруг запуска скрипта, а не полагаться на то, что вы успеете вручную запустить tcpdump в нужную секунду.

sudo crontab -l -u имя_пользователя -e
# было:  */5 * * * * /opt/scripts/подозрительный_скрипт.sh
# стало: */5 * * * * /opt/cron-wrappers/capture-подозрительный_скрипт.sh

Пока враппер не готов — следующие два раздела как раз про то, что в него положить.

tcpdump: перехват исходящего трафика именно этой задачи

У tcpdump нет встроенного флага «фильтровать по PID» — фильтрация идёт по интерфейсу, адресу, порту и протоколу, а не по процессу. Поэтому перехват строится в два шага: сначала захватываете широкий диапазон трафика вокруг момента запуска скрипта, потом сопоставляете найденные соединения с самим процессом через ss/lsof.

Враппер, который оборачивает запуск скрипта захватом на всех интерфейсах, за вычетом локальной петли:

#!/bin/bash
# /opt/cron-wrappers/capture-подозрительный_скрипт.sh
mkdir -p /var/log/cron-capture
CAP=/var/log/cron-capture/скрипт-$(date +%s).pcap

tcpdump -i any -w "$CAP" 'not host 127.0.0.1 and not host ::1' &
TCPDUMP_PID=$!
sleep 1                       # дать tcpdump подняться до старта скрипта

/opt/scripts/подозрительный_скрипт.sh &
SCRIPT_PID=$!
wait "$SCRIPT_PID"
CODE=$?

sleep 2                       # доловить хвост соединения после завершения
kill "$TCPDUMP_PID" 2>/dev/null
exit $CODE

Сразу после запуска, пока враппер работает, полезно параллельно снять снимок сокетов процесса — это даст прямую привязку PID к адресу:

ss -tnp | grep -v 127.0.0.1
lsof -p $(pgrep -f подозрительный_скрипт.sh) -i 2>/dev/null

После завершения задачи pcap-файл читается офлайн:

tcpdump -r /var/log/cron-capture/скрипт-*.pcap -nn

Если трафик идёт по чистому HTTP без шифрования, флаг -A покажет содержимое пакетов ASCII-текстом прямо в выводе — это самый быстрый способ увидеть тело запроса:

tcpdump -r /var/log/cron-capture/скрипт-*.pcap -nnA 'tcp port 80'

Для HTTPS тело зашифровано, но и здесь tcpdump/tshark дают полезную зацепку — SNI (Server Name Indication) в TLS Client Hello передаётся открытым текстом, потому что сервер должен понять, какой сертификат отдавать, ещё до установления шифрованного канала:

tshark -r /var/log/cron-capture/скрипт-*.pcap -Y "tls.handshake.extensions_server_name" -T fields -e tls.handshake.extensions_server_name

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

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

strace: что скрипт пишет в сокет — и честная граница метода

strace перехватывает системные вызовы, и для сетевой активности интересны в первую очередь connect, sendto, recvfrom, write, read:

strace -f -e trace=network -p $(pgrep -f подозрительный_скрипт.sh) 2>&1 | tee /var/log/cron-capture/strace-net.log

Аргумент connect() покажет точный IP и порт назначения даже если само подключение потом идёт через пул соединений или прокси внутри библиотеки — это самая надёжная часть метода, которая работает всегда, независимо от шифрования.

А вот с содержимым буферов write/sendto не всё так однозначно, и здесь важно не обмануться. Если протокол — обычный HTTP, буфер, уходящий в сокет, содержит открытый текст запроса, и его можно прочитать прямо в выводе strace, увеличив длину строки флагом -s:

strace -f -e trace=network,write -s 2000 -p PID 2>&1 | grep -A5 sendto

Если же соединение идёт по TLS (а в конце августа 2026 года это подавляющее большинство исходящих интеграций), шифрование в curl, Python requests и подобных клиентах происходит в userspace ещё до вызова write()/sendto() на сокет. В буфере, который увидит strace, уже лежат зашифрованные байты, а не читаемый текст. strace на уровне syscall честно покажет факт и объём передачи, но не содержимое — выдавать зашифрованную кашу за «мы прочитали, что он шлёт» было бы неверно.

Для реального чтения тела HTTPS-запроса без обладания приватным ключом сервера назначения (которого у вас, разумеется, нет) практичный путь — не расшифровка перехваченного трафика, а перенаправление запроса через локальный прокси, который сам выступает MITM для этого одного процесса. Curl, requests и большинство HTTP-клиентов в скриптах уважают переменные окружения http_proxy/https_proxy — их можно временно включить прямо во враппере:

#!/bin/bash
# добавить в тот же враппер перед запуском скрипта
export http_proxy=http://127.0.0.1:8080
export https_proxy=http://127.0.0.1:8080

С поднятым локально mitmproxy (mitmdump -p 8080 -w /var/log/cron-capture/mitm.flow) вы получаете и точный URL, и заголовки, и тело запроса в читаемом виде — потому что прокси сам держит сертификат и терминирует TLS с обеих сторон. Метод не сработает для клиентов, которые игнорируют переменные прокси или пиннят сертификат сервера (некоторые SDK так делают намеренно) — в этом случае остаётся то, что дают connect() из strace и SNI из tcpdump: адрес назначения известен точно, содержимое — нет, и это тоже результат, просто более узкий.

Легитимная интеграция или эксфильтрация: на что смотреть

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

ПризнакПохоже на легитимную интеграциюПохоже на эксфильтрацию/бэкдор
Адрес назначенияДомен, резолвится стабильно, принадлежит известному сервису (платёжный шлюз, CRM, мониторинг)Голый IP в коде вместо домена, ASN — рядовой хостинг/VPS без связи с бизнесом
Сертификат при HTTPSВалидный, имя в сертификате совпадает с адресом назначенияСамоподписанный, либо SNI и сертификат расходятся, либо порт нестандартный без TLS вообще
РасписаниеСовпадает с бизнес-логикой (раз в сутки — сводный отчёт, раз в час — синк курсов)Странный ровный интервал (каждые 5 минут круглосуточно) без видимой бизнес-причины — типичный маячок
Объём и характер трафикаПропорционален смыслу задачи (выгрузка отчёта — мегабайты раз в день)Маленький фиксированный пакет через равные интервалы — «heartbeat», типичный для C2
Порт назначения443/80 или порт известного API4444, 1337, 6666 и другие типовые порты handler'ов Metasploit и учебных reverse-shell пейлоадов
Содержимое (когда видно)JSON с узнаваемыми полями бизнес-данных, соответствует названию задачиbase64-блок без структуры, команды shell, содержимое /etc/passwd, переменные окружения с секретами
Происхождение скриптаЕсть в репозитории инфраструктуры, git blame ведёт к живому автору из командыФайл появился без записи в git/деплое, дата изменения не совпадает ни с одним релизом
Куда ссылается сам кодКомментарии, README, упоминание в документации проектаОбфусцированный код, eval, динамическая сборка команды из фрагментов строк

Ни один признак по отдельности не доказателен: у легитимной интеграции тоже иногда бывает захардкоженный IP, а маячок атакующего иногда мимикрирует под правдоподобное имя хоста. Разбор реального инцидента, где именно такая маскировка — cron-задача с reverse shell на порт 4444 каждые пять минут под видом рутины — оказалась компрометацией через открытый Docker API, подробно описан в статье reverse shell в cron у пользователя, которого никто не заводил: там видно, какие сигналы выдали атаку.

Куда ведёт адрес: whois, обратный DNS и репутация

Когда точный адрес назначения известен (из connect() в strace, из ss -tnp или из pcap), следующий шаг — понять, кому он принадлежит.

whois <IP>

В выводе важны поля OrgName/org-name, netname, диапазон сети и страна. Крупный облачный провайдер (AWS, Google Cloud, Azure, Cloudflare) ни о чём однозначно не говорит: и легитимные SaaS, и C2-инфраструктура сплошь и рядом арендуют мощности у тех же облаков. А вот малоизвестный хостер в стране, никак не связанной с вашим бизнесом, — повод присмотреться внимательнее.

Обратный DNS — ещё одна быстрая проверка:

dig -x <IP> +short

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

Дальше стоит свериться с открытыми базами репутации IP — сервисы вроде AbuseIPDB и VirusTotal собирают сообщения от других администраторов о том, что адрес замечен в сканировании, спаме или как C2-инфраструктура. Учтите: чистая репутация не доказывает безобидность (новый C2 может быть ещё нигде не засвечен), а плохая — по одному старому сообщению не всегда актуальна: смотрите на дату и число независимых сообщений, а не на сам факт записи.

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

Что делать с находкой: собрать улики, потом изолировать

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

Сначала зафиксировать состояние, не трогая процесс:

ps -ef | grep PID
lsof -p PID > /var/log/cron-capture/lsof-snapshot.txt
cat /proc/PID/environ | tr '\0' '\n' > /var/log/cron-capture/env-snapshot.txt
sha256sum /opt/scripts/подозрительный_скрипт.sh

environ процесса стоит посмотреть отдельно — если скрипту передали в переменных окружения API-токен, ключ доступа к базе или облачные credentials, именно это может утекать наружу вместе с легитимным на первый взгляд запросом, и это первое, что нужно перевыпустить при подтверждении утечки.

Только после этого — решение по самому процессу и задаче. Если по совокупности признаков это легитимная интеграция без документации — не трогаем работу, просто заносим находку в реестр сервера с владельцем и назначением (тот же принцип, что и для остальных находок при разборе чужого cron). Если признаки указывают на эксфильтрацию или бэкдор — задачу гасят по тому же правилу «комментируем с датой и причиной, не удаляем», сам вредоносный процесс завершают, а если он получал доступ к чему-то чувствительному — перевыпускают затронутые ключи и токены.

Отдельно стоит перекрыть исходящий канал сразу, ещё до полного разбора, если признаки достаточно тревожны (порт из списка reverse-shell handler'ов, адрес с плохой репутацией, обфусцированный код) — точечным правилом на конкретный адрес, а не полной остановкой сервиса:

iptables -A OUTPUT -d <IP> -j DROP

Если находка подтвердилась как компрометация, а не забытая интеграция, дальнейшие шаги — это уже не про один cron, а про полноценное реагирование на инцидент: смена credentials, поиск других точек закрепления, решение о передеплое из чистого бэкапа. Общий порядок этих действий подробно расписан в чек-листе действий при взломе сервера.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Cron-задача выполняется меньше секунды — как вообще успеть перехватить трафик?

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

tcpdump ничего не показал за интервал выполнения, а исходящий трафик точно есть — в чём может быть дело?

Проверьте, что скрипт действительно запускался в это окно (сверьте с логом враппера или journalctl для cron), что фильтр not host 127.0.0.1 не отфильтровал нужный интерфейс в контейнере/network namespace, и что задача не резолвит DNS через отдельный процесс с задержкой — иногда соединение открывается на несколько секунд позже, чем кажется по расписанию.

Обязательно ли поднимать mitmproxy, если просто нужно понять, легитимна ли задача?

Нет, для большинства случаев достаточно связки connect() из strace (точный адрес и порт) плюс SNI из tcpdump (доменное имя при HTTPS) плюс whois/репутация адреса. MITM-прокси нужен только тогда, когда важно увидеть само тело запроса — например, чтобы понять, какие именно данные уходят, а не просто куда.

Скрипт — скомпилированный бинарник без исходников, что тогда?

Статический анализ кода здесь недоступен, но strace и tcpdump работают одинаково для любого исполняемого файла независимо от языка — перехват на уровне syscall и сети не зависит от того, есть ли у вас исходный код. Если нужно понять логику внутри бинарника глубже, чем даёт сетевой перехват, это уже выходит за рамки задачи одного расследования и требует отдельного реверс-инжиниринга.

Можно ли судить о вредоносности только по плохой репутации IP, без анализа самого трафика?

Как единственное основание — нет. Плохая репутация — весомый аргумент за более пристальную проверку, но не замена ей: адрес мог сменить владельца, репутационная запись — устареть, а хорошая репутация нового или редко замеченного C2-сервера может просто ещё не накопиться. Решение принимайте по совокупности: содержимое трафика, происхождение скрипта, whois и репутация вместе, а не по одному пункту.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →