Хэллоуин только прошёл, так что расскажу про две свои волшебные штуки (названия отсылают к каминной сети и к заклинанию из Поттера). Обе открытые, обе написаны на bash. Floo нужен, чтобы зайти на чужую Linux-машину, сделать дело и уйти, не оставив за собой двери. Homenum Revelio нужен, чтобы посмотреть, кто ещё может войти на машину, которая теперь ваша.
Пару недель назад я настраивал свою Steam Machine. Включил на ней SSH руками, закинул ключ, сделал что хотел. Уже потом подумал, что в следующий раз пойду через floo. Ключ так и лежит в authorized_keys, и пролежит там, пока я про него не вспомню.
Ситуация повторяется постоянно, меняется только железо. Кому-то (человеку или агенту) надо один раз попасть на устройство и что-то там настроить. Это может быть сервер, VPS, ноутбук друга, Steam Deck. Открывать на нём порт не хочется, оставлять постоянный доступ тем более. TeamViewer показывает рабочий стол, а нужен терминал. Teleport отличный, когда машины твои, но ради одного вечера на чужом Deck его никто разворачивать не будет.
Мне хотелось TeamViewer для терминала. Человек запускает одну команду, диктует код, и помощник уже внутри. И чтобы доступ был временный, всё писалось, а оборвать можно было в любую секунду. Готового такого, без установки и одной командой, я не нашёл (может, плохо искал)). Floo вырос из поддержки клиентских серверов одного моего продукта, потом я вынес его в отдельный репозиторий.
Камин и летучий порох
В книгах есть каминная сеть. Бросаешь в огонь горсть летучего пороха, называешь адрес и выходишь из чужого камина. У меня сеть это relay. Тот, кому нужна помощь, подключает к ней свой камин командой floo, а помощник бросает порох, floo-powder.
Первым запускает человек у машины:
curl -fsSL https://raw.githubusercontent.com/interkelstar/floo/v0.8.2/floo \ | bash -s -- --public --relay relay.floo.run --pin df2b83ff925f89bd
На экране появляется код. Он диктует его помощнику, и тот запускает у себя:
bash <(curl -fsSL https://raw.githubusercontent.com/interkelstar/floo/v0.8.2/bin/floo-powder) \ --relay relay.floo.run --pin df2b83ff925f89bd connect КОД-КОТОРЫЙ-ПРОДИКТОВАЛИ
После этого у помощника shell на той машине.
Диктовать такое по телефону, а потом набирать на Steam Deck невозможно, поэтому на этой неделе у проекта появился свой домен, а публичный relay и его пин переехали внутрь скриптов. Теперь всё то же самое выглядит так:
curl -s https://floo.run/sh | bashbash <(curl -s https://floo.run/powder) connect КОД
За floo.run отдаются байты тега последнего релиза (сейчас v0.8.2). /sh это 23 строки, которые скачивают floo этого тега и запускают его с --public, единственное, что человек у машины запускает не читая. Отдаёт всё это маленький Cloudflare Worker, он тоже лежит в репозитории, в web/.
Наружу машина не открывает ни одного порта. floo сам звонит на relay по 443, его обычно выпускают даже строгие файрволы. Потом поднимает одноразовый sshd на localhost и пробрасывает его на relay обратным unix-сокетом. Пока процесс жив в терминале, доступ есть. Нажали Ctrl-C или закрыли окно, и его нет.
Сам relay это отдельный изолированный sshd с одним бесправным аккаунтом gw, которому разрешено держать такой сокет и больше ничего. Внутрь сессии он не заглядывает. SSH помощника заканчивается на sshd клиента, а relay просто склеивает два потока шифротекста.
Кто есть кто, проверяется с трёх сторон. Помощник предъявляет машине SSH-сертификат, который живёт не дольше часа и подписан его CA (в быстром режиме вместо сертификата работает код, до него дойду). Relay обе стороны узнают по закреплённому host key (флаг --pin). Машину помощник узнаёт по коду, который ему прочитали вслух. Relay хранит от этого кода только SHA-256.
Файлов два, а не один с подкомандами. Человек у машины запускает чужой код у себя, и ему достаточно прочитать свою половину. Про CA, приглашения и установку relay ему знать не нужно.
Первые живые машины
В первой версии сессия на relay называлась по имени машины. Работало, пока имена не совпадали. Но у оператора с несколькими клиентами два одинаковых hostname встречаются сплошь и рядом, так что в v0.2 я переделал адресацию на случайный идентификатор сессии плюс код. Имя осталось подписью для людей. Проверял на двух контейнерах, оба назывались twin, и каждый спокойно жил со своим кодом.
Самая весёлая история случилась на первом настоящем сервере. Публичный ключ CA тогда передавался прямо в командной строке, флагом --operator-ca. Человек вставлял команду в веб-терминал Cockpit, а тот при вставке переносил длинную строку посреди ключа. На диск ложился битый CA, и sshd отвечал на любой сертификат Permission denied (publickey).
Сначала подумали на PAM и какое-то время копали не туда. Ответ всё это время лежал в логе клиентского sshd: там была строчка Failed publickey … CA <fp>, а строчки Accepted certificate не было. Это значит, что sshd просто не доверяет такому CA.
После этого CA перестал ездить через буфер обмена. Клиенту теперь передаются адрес relay и 16 hex отпечатка его host key. floo подключается, сверяет отпечаток и уже по проверенному каналу забирает у relay публичный CA оператора. Ломать при вставке стало нечего. В сентябре у этой схемы нашлось слабое место, расскажу ниже.
Ещё одну ошибку я сделал сам и заметил уже после релиза. В v0.3.0 я сделал floo-powder самодостаточным: весь код relay вместе с установщиком лежит прямо внутри файла, клонировать ничего не надо. Встроил я его в base64, потому что у скриптов relay свои heredoc’и и разделители могли подраться. Получилось, что floo-powder init запускает через sudo кусок кода, который в less выглядит как стена букв. У проекта девиз «Don’t trust us — read us», а прочитать как раз то, что получит root, было нельзя. В v0.3.1 relay встроен обычным bash в heredoc с кавычками и уникальным разделителем. Тест падает, если встраивание снова станет base64, а scripts/embed.sh --check сверяет встроенную копию с исходниками в relay/.
Тег v0.3.0 я при этом не трогал. Однострочники ссылаются на тег версии, на репозитории стоит правило, по которому теги v* нельзя передвинуть, обновить или удалить. Если человек прочитал файл на GitHub по тегу, запустится тот же файл, поэтому исправление ушло в v0.3.1, а v0.3.0 так и висит рядом.
Быстрый режим
Для случая «помоги прямо сейчас» сертификаты и CA избыточны. В v0.4 появился --public: сессию может взять любой, у кого есть код. Тогда код сам становится паролем, поэтому он длинный, 13 символов base32, около 65 бит, по четыре в группе. Я прикидывал список слов, диктовать его приятнее. Но на 64 бита нужен словарь примерно на 1700 слов, это около 13 КБ, файл, который должен оставаться читаемым, вырос бы на 40%. Base32 никаких данных не требует.
Ошибку в дизайне быстрого режима поймали ещё на этапе плана. Список сессий на relay видит любой, кто до него достучится. Если бы оператор занимал сессию первой записью, любой мусорщик мог бы записаться раньше и заблокировать её до конца срока. Поэтому relay складывает все попытки привязки, до 64 на сессию, а клиент сам выбирает ту, у которой сходится HMAC(code, key). Мусор лежит рядом и ни на что не влияет.
Личность оператора в этом режиме relay не хранит, у него есть только хеш кода и HMAC. Хозяин публичного relay может уронить сервис или посмотреть метаданные, но тихо читать сессию у него не получится. Режим включается отдельно на каждой стороне, по умолчанию остаётся CA.
Что видит тот, кому помогают
Первые версии показывали клиенту только статус, а сами команды шли во второе окно. В веб-терминале с одним окном толку от этого мало. В v0.5 команды помощника пошли в то же окно живым логом: $ команда, под ней вывод, внизу прибита строка статуса с подсказкой про Ctrl-C.
Вывод полностью в руках помощника. Если отрисовка строится по маркерам в потоке, их можно подделать и нарисовать клиенту что угодно. Поэтому хуки bash и zsh пишут невидимые OSC-маркеры, подписанные секретным nonce, у каждой сессии свой. Рендерер верит только маркерам с правильным nonce и вырезает из вывода управляющие последовательности. vim и top показываются как есть, без попыток прятать полноэкранные программы, потому что такой фильтр тоже можно было бы использовать, чтобы спрятать настоящий вывод.
Вся сессия пишется на диск клиента, в ~/.floo-last-session/recording/. Рядом с читаемым session.log лежит session.raw, сырой поток байтов без всяких фильтров. На выходе floo показывает, изменилось ли что-то в SSH-ключах, включённых сервисах и cron.
Помощник при этом работает от того же пользователя. Захочет, прочитает nonce или снимет хуки. Живой лог помогает видеть, что делает помощник, который не прячется, а от того, кто прячется, он не спасёт.
После нескольких раундов ревью консоли я подключился к живому серверу и увидел пустое окно. tail -F следил за recording/*.log, glob раскрывался в момент старта, когда файлов ещё не было, и tail терпеливо ждал файл с буквальным именем *.log. Тесты проверяли запись после сессии и ни разу не смотрели на живой хвост. В следующей версии зависло моё первое подключение в быстром режиме: проверка готовности пробрасывала stdin терминала, а рекордер на стороне клиента читает stdin до конца. В тестах stdin был закрыт. Ещё через версию вылезли пустые строки $ для команд вида ssh host 'команда', тестовая петля гоняла только floo-powder exec. Три версии подряд режим, который я объявил поддерживаемым, не работал у меня самого. v0.6 после этого целиком ушла на починку и упрощение, а --watch и --status я просто удалил.
Потом были сироты. sshd и туннель живут в своей группе процессов, чтобы на выходе убить их разом. Если сам floo умер некрасиво (SIGKILL, закрытый tmux), группа оставалась жить. В v0.7.1 появился floo --stop, который из другого терминала либо просит живой floo завершиться, либо добивает сирот и проверяет, что порт закрыт. В v0.7.3 к нему добавился сторож, который гасит sshd, как только пропадает управляющий процесс. Самая надёжная страховка всё равно перезагрузка, после неё от доступа ничего не остаётся.
Летом я всерьёз сомневался, прилично ли показывать людям около 1200 строк bash в клиенте и тысячу в операторском файле. Модули или Go выглядели бы солиднее, только тогда человек у машины запускал бы уже не один файл, который можно прочитать целиком. Остался bash. К нему добавились shellcheck в CI, баннеры разделов и docs/READING.md, карта, с которой оба скрипта читаются минут за двадцать.
Сентябрь и утро с таблицей маршрутизации
Писал я оба инструмента вместе с ИИ-агентами, ими же и проверял. В сентябре одни и те же байты обоих репозиториев прогнали два независимых сканера: VVAH, открытый агентный сканер от Visa, и codex-security от OpenAI. Подробное сравнение этих двух я выкладывал в X ещё в сентябре. По floo они не совпали ни в одной строке кода, зато сошлись в четырёх механизмах. Я теперь смотрю на такие совпадения по механизму и почти не смотрю на номера строк. Удалённой дыры в клиентском коде не нашёл ни один, а каждую находку я сначала воспроизводил руками и только потом чинил.
Одна из них вернула меня к истории с Cockpit. --pin подтверждал, что relay настоящий, но CA, который потом пускает помощника на машину, приходил с того же relay. Пока relay свой, разницы нет. Если relay чужой, его хозяин мог подсунуть свой CA. Теперь у пина есть вторая половина:
floo --relay relay.example.com --pin RELAY16:CA16
Первые 16 hex проверяют host key relay, вторые отпечаток CA оператора. Без второй половины всё работает как раньше, только floo пишет одну строку о том, что доверяет хозяину relay выдать правильный CA.
Это и ещё несколько исправлений вошли в v0.7.3. Тег я нарезал в день, когда дописывал эту статью, и первое же обновление моего публичного relay его сломало. Шаблон конфига sshd в установщике пишется heredoc’ом без кавычек, чтобы подставлялся порт. В свежий комментарий в этом шаблоне попало слово route в обратных кавычках. На машине с net-tools bash послушно выполнил route и вписал в sshd_config таблицу маршрутизации. Рядом нашёлся второй баг. При обновлении живого relay systemctl enable --now уже запущенный sshd не трогает, и тот продолжал работать со старым конфигом, а новый диспетчер уже требовал ExposeAuthInfo и отказывал всем быстрым сессиям.
Так появился v0.7.4, в нём кривой конфиг останавливает установку, а живой relay получает SIGHUP и перечитывает конфиг, не обрывая сессий. Тег v0.7.3 никуда не делся. Передвинуть его мне не даёт моё же правило, так что он лежит в репозитории со словом route в обратных кавычках.
Homenum Revelio
В книгах это заклинание показывает, есть ли рядом люди. revelio показывает, у кого остался вход на машину, которая досталась вам: купленный или унаследованный VPS, сервер подрядчика, коробку, которую кто-то для вас настроил.
Он печатает инвентаризацию постоянных путей доступа, от SSH-ключей и sudoers до исходящих туннелей, cron, PAM и setuid-бинарников. На curl | sh в cron ругается особенно громко. Про каждую запись говорит, принадлежит она пакету дистрибутива или её туда положил человек.
Слово SAFE он не печатает никогда. Он выдаёт факты, а узнавать среди них своё придётся вам. Код выхода тоже не вердикт. 0 значит, что смотреть не на что, 10 значит, что есть записи, на которые стоит взглянуть, а 1 значит, что сломался сам инструмент.
curl -O https://raw.githubusercontent.com/interkelstar/homenum-revelio/v0.1.0/revelioless reveliobash revelio
Просить вслепую доверять инструменту для поиска бэкдоров было бы смешно, поэтому отсутствие сети в нём проверяется одним grep:
grep -nE 'curl|wget|/dev/tcp|/dev/udp|nc |ncat|telnet' revelio
Он находит две строки с регулярками, по которым скрипт ищет curl | sh и reverse shell в чужих cron и профилях, и комментарий над ними. Команд там нет. В README до последнего было написано, что grep не находит ничего. Неправда, поправил вместе с тегом v0.1.0.
На диск revelio пишет только файл --save, если его попросили. Root ему не нужен, без root он перечисляет, чего не смог прочитать. Ключ с хитрым комментарием или файл с escape-последовательностями в имени ничего не нарисуют в вашем терминале, управляющие байты заменяются на ?.
Мой любимый трюк это REVELIO_ROOT=/mnt revelio. Так он смотрит на смонтированный образ диска вместо живой системы, и сервер можно проверить, ничего на нём не запуская.
Сентябрьские сканеры посмотрели на него по-разному. VVAH искал баги в самом скрипте и нашёл, например, небезопасную обработку REVELIO_ROOT и запись --save через симлинк. codex-security читал его как средство контроля и искал, что он пропустит. Его находки были про переименованные бинарники туннелей, systemd-таймеры, @include в sudoers и аккаунты вне /home. Для программы, которой проверяют, не остался ли у прежнего хозяина вход, такой пропуск хуже любого бага в коде.
Оценок защищённости, антивирусных сигнатур и обещаний найти руткит в нём нет. Скрипт в пространстве пользователя не может это доказать, поэтому он утверждает одно: вот все входы, которые удалось прочитать. У floo на выходе свой маленький дифф по трём местам, а если нужно глубже, помощник может прогнать revelio прямо в сессии.
Где floo не подойдёт
Публичный relay relay.floo.run я держу по доброй воле и без гарантий. Быстрая сессия живёт до 30 минут, одновременных сессий ограниченное число. Для постоянной работы или чего-то чувствительного лучше поднять свой relay командой floo-powder init (Fedora, Debian, Ubuntu, Arch, Alpine; --uninstall убирает за собой всё) и работать с CA.
floo даёт shell от имени того пользователя, который его запустил. Root он не даёт, sudo спросит пароль как обычно. На машине должен быть бинарь sshd, установленный пакет, запущенный сервис не нужен. Для живой консоли нужен python3, для записи pty script из util-linux. Без них всё работает, просто скромнее выглядит.
Запись пишется от того же пользователя, под которым работает помощник, так что подделать её он может. floo обещает, что изменения на машине видно, а не что их нельзя сделать. Доступ заканчивается вместе с процессом и уж точно с перезагрузкой.
Теперь про curl | bash, этот комментарий всё равно будет первым. Однострочник ссылается на неизменяемый тег, поэтому файл, прочитанный на GitHub по этому тегу, и запустится. Операторский однострочник умеет закрепить ещё и полный хеш коммита. А можно скачать, прочитать и запустить руками:
curl -fsSL https://floo.run -o flooless floobash floo --public
Подписи релиза sha256 по второму каналу пока нет, в модели угроз она записана как принятый остаточный риск.
Если у вас десятки своих машин, RBAC и комплаенс, берите Teleport или Boundary. floo для машин, которые вам не принадлежат, и для одного вечера.
Ссылки
-
Floo: github.com/interkelstar/floo, рядом docs/DESIGN.md, docs/THREAT-MODEL.md и docs/READING.md
-
Короткий вход: floo.run
-
Homenum Revelio: github.com/interkelstar/homenum-revelio
Оба под MIT. Если однажды ночью к вам постучится друг с ноутбуком и словами «оно само», можно помочь и не оставить у него в доме запасного ключа. Ctrl-C, и шалость удалась.
ссылка на оригинал статьи https://habr.com/ru/articles/1104098/