Одна звёздочка, десять лет: тихий баг SSH в Alpine Linux
В Alpine Linux баг SSH десять лет пускал в аккаунты с паролем длиннее 256 символов. Разбираем, как особенность musl crypt() создала эту дыру.

Вот число, которое должно вас насторожить: 257. Примерно десять лет ввод любого пароля длиной 257 символов и больше в SSH-запросе на определённом типе Linux-систем не приводил к отказу, а пускал внутрь. Любой пароль. Хаотичный набор клавиш, base64-дамп фотографий с отпуска, полсообщения из Slack, вставленного по ошибке: если строка была достаточно длинной, дверь открывалась. Уязвимость Alpine Linux SSH, раскрытая под названием «sshole», из тех, что звучат как розыгрыш, пока не проверишь условия. Тогда она читается как отчёт об инциденте.
Вкратце: в OpenSSH, собранном с musl libc (это Alpine, OpenWrt, Buildroot, Yocto и огромная доля Docker-образов в интернете), учётные записи, в которые по замыслу невозможно войти, принимали любой пароль длиной от 257 до 1 024 символов. Root. Ваш пользователь деплоя, который работает только по ключу. Пользователь по умолчанию alpine в официальных образах Alpine для AWS и Azure, где настроен doas без пароля. Исправление вышло в OpenSSH 10.6p2. Но история того, как это случилось, представляет собой цепочку мелких разумных решений, тянущуюся до 1980 года, и каждое звено в своё время выглядело правильным.
Как на самом деле работает баг Alpine Linux SSH
Чтобы понять это столкновение, нужны три части, и каждая принадлежит своей кодовой базе и своему десятилетию. Первое: как Linux хранит пароли. Когда вы задаёте пароль, хеш попадает в /etc/shadow, по одной строке на аккаунт. Строка обычного аккаунта выглядит как $6$salt$hash, где $6$ означает sha512crypt. Но вместо хеша аккаунты, которые не должны принимать пароль (демоны, пользователи только с ключами, заблокированный root), получают заглушку:
alice:$6$yJ3kXw9q$k2P9vLm4...:19723:0:99999:7:::
deploy:*:19723:0:99999:7:::
root:*:19723:0:99999:7:::
При входе sshd берёт то, что вы ввели, хеширует это с сохранённой солью и сравнивает строку с сохранённым полем. Совпало, значит, вы внутри. Звёздочка служит сигнальным значением: она не является хешем ничего, поэтому ни один пароль не может ей совпасть. Так учётные записи только с ключами блокировались со времён ранних Unix. Это работает, потому что сравнение никогда не может пройти, если только сама функция хеширования не умеет выдавать звёздочку. Что подводит нас ко второй части.
Вторая часть связана с обработкой ошибок в musl. Традиционно crypt() возвращает NULL, когда не может выполнить работу, например если соль требует алгоритма, которого в системе нет. Проблема в том, что десятилетиями программы не проверяли NULL и спокойно передавали его в strcmp, получая сегфолт. Поэтому в 2012 году musl сделала осознанный выбор: её crypt() никогда не возвращает NULL. При ошибке она возвращает строку, которая гарантированно не совпадёт ни с одним настоящим хешем пароля, чтобы сломанные вызывающие получали неверный пароль вместо падения. Строкой ошибки стала *. Через три недели в musl добавили sha512crypt с ограничением входа в 256 байт, ведь стоимость хеширования растёт с длиной пароля и огромные пароли становятся вектором отказа в обслуживании. Всё длиннее лимита считается ошибкой, а ошибка возвращает *. Комментарий, который это документирует, всё это время лежал в исходниках.
Третья часть связана с вежливостью sshd. В 2016 году CVE-2016-6210 показала, что можно перебирать существующие имена пользователей по времени ответа: несуществующие пользователи получали мгновенный отказ, потому что хешировать было нечего. Исправление было изящным: для аккаунтов без пригодного хеша sshd берёт соль другого аккаунта и всё равно хеширует введённый пароль, просто чтобы потратить столько же времени. Медленное «нет». Загвоздка в том, что этот путь срабатывает не только для несуществующих пользователей. Он срабатывает и для настоящих пользователей, у которых в shadow стоит *, потому что звёздочка не является пригодным хешем. Так что теперь sshd вычисляет crypt(ваш_пароль, взятая_соль) и сравнивает результат с *.
/* what sshd effectively does in this configuration */
const char *stored = shadow_field(user); /* "*" for locked accounts */
const char *salt = pick_salt(user); /* borrowed: "*" isn't a valid hash */
char *ret = crypt(typed_password, salt); /* musl: "*" if password > 256 bytes */
if (ret && strcmp(ret, stored) == 0)
allow_login(); /* "*" == "*" → welcome in */
Все элементы должны совпасть: в поле стоит *, crypt завершается ошибкой, а значение ошибки тоже *. В glibc неудачный crypt возвращает NULL, что безопасно даёт отказ при сравнении. В системах с libxcrypt строка ошибки равна *0: отличие на один символ, и этот символ решает всё, потому что *0 никогда не равно *. В musl значение ошибки и значение блокировки совпадают, а strcmp не умеет отличить ошибку от совпадения. Два смысла на одном канале, и получатель не может их различить.
Десятилетие абсолютно разумных решений
Что делает этот баг интересным для изучения, так это не механизм, а то, что никто из участников не сделал ничего очевидно глупого. Начнём с 1980 года: тогда хеши паролей лежали в общедоступном /etc/passwd, Билл Джой создал учётную запись daemon, в которую никто никогда не должен входить, и поставил * вместо хеша пароля. Чистая сигнальная метка, опередившая своё время. (Поле с реальным именем, по слухам, гласило «the devil himself».) Unix до сих пор блокирует учётные записи именно так.
Решение musl 2012 года тоже выглядит оправданным с любой стороны. Падающие вызывающие код — реальная проблема, а C-библиотека, которая обращает NULL против десятилетий небрежного кода приложений, наживает много врагов. Возврат строки, которая никогда не совпадёт, это мягкая деградация. Ограничение длины пароля, чтобы остановить отказ в обслуживании через хеширование, тоже разумно. Разумна и правка тайминга в sshd: дополнение ответов для пользователей без хеша стандартная практика. Даже то, что Alpine сделал дальше, выглядело логично. В 2019 году CVE-2019-5021 показала, что в Docker-образах Alpine поле пароля root было пустым, а в некоторых конфигурациях пароля не было вообще. Alpine заблокировал root символом !. Но без PAM sshd трактует ! как полную блокировку, что убивает и вход по SSH-ключам, а облачные образы живут входом по ключам. Поэтому в 2022 году Alpine 3.16 перевёл root на *: по-прежнему заблокирован, ключи работают, все довольны. Вот только * была как раз той строкой, которую можно было заставить выдать musl crypt.
Каждый шаг был обоснованным ответом на реальную проблему. Именно это и неуютно: вы могли бы присутствовать при каждом решении и одобрительно кивать.
Почти-промахи даже хуже. Разработчик Debian однажды попросил glibc перенять точное поведение musl с *; шесть лет спустя Debian перешёл на libxcrypt, чей *0 закрыл дыру случайно. В обсуждении BusyBox жаловались, что passwd спотыкается на пароле в 257 символов. Та же звёздочка, тот же лимит, и никто в ветке не сказал «sshd». Рецензент безопасности патчил функцию pick_salt() в sshd, когда сам баг находился в четырёх строках ниже, за четыре дня до того, как его наконец нашли. Мимо него снова и снова проходили люди, которые смотрели почти прямо на него.

Затронута ли ваша инфраструктура багом musl в OpenSSH?
Спасает sshole то, что для срабатывания нужен полный набор условий. Пропустите любое, и вы в безопасности. Вы уязвимы, если все эти условия выполняются:
- Ваш sshd собран с musl: Alpine, OpenWrt, Buildroot, Yocto или Docker-образ на основе
alpineс установленнымopenssh-server. - sshd не работает через PAM: openssh-server в Alpine по умолчанию так не делает, или вы задали
UsePAM no. Путь кода PAM никогда не вызывает crypt для учётной записи с*. - Вход по паролю включён. По умолчанию он включён.
- Хотя бы одна учётная запись имеет голую
*в/etc/shadow: пользователи только с ключами, учётные записи из LDAP, root в Alpine. - Хотя бы у одной другой учётной записи есть настоящий хеш пароля, так что есть соль для заимствования.
Выполните все пять условий, и каждая учётная запись с * на машине примет любой пароль от 257 до 1 024 символов. Кошмарная конфигурация — официальные образы Alpine для AWS и Azure: пользователь по умолчанию alpine работает только по ключу (то есть с *) и имеет doas без пароля. Вход с длинным паролем там — не эскалация привилегий, а парадный вход прямо к root. Одному обстоятельству стоит отдать должное за то, что оно так долго держало баг в тени: PermitRootLogin в OpenSSH запрещает вход по паролю для root с 2015 года, так что самая очевидная цель частично защищалась по умолчанию. Ничто не защищало пользователей деплоя и сервисные учётные записи.
Если вы на Debian, Ubuntu, Fedora или чём-то ещё на базе glibc, вы в порядке. Всё, что идёт через PAM, тоже в порядке. Dropbear: в порядке. OpenBSD: в порядке. Одно предостережение для скептиков: ранние попытки сообщества воспроизвести баг давали разные результаты, потому что пять условий легко пропустить по одному. Кто-то пробует официальный образ Alpine, не замечает, что поле root содержит !, а не *, не получает результата и идёт дальше. Фраза «у меня на ноутбуке не воспроизводится» не является доказательством ничего в продакшене. Проверяйте условия, а не ощущения.
Настоящая первопричина: внутриполосная сигнализация и магические строки
Соблазнительно отнести sshole к категории «странный крайний случай libc» и двигаться дальше. Режим отказа при этом старше и шире: это внутриполосная сигнализация. Символ * был задействован сразу в двух ролях. В /etc/shadow он означает «для этой учётной записи пароля нет», а в crypt musl «хеширование не удалось». Два сообщения по одному каналу, и получатель не может их различить. Сигнальные значения, встроенные в обычные данные, именно так системы начинают эксплуатировать сами себя. NULL был приемлемым сигналом ошибки (несмотря на падающих вызывающих). Исключение лучше. Лучше всего Result<T, E>, который заставляет вызывающий код явно обрабатывать ошибку. Практическое правило, которое я бы набил татуировкой каждому системному программисту: никаких магических строк. Если без них совсем никак, выбирайте значение, которое не может совпасть ни с одним значением из предметной области. *0 из libxcrypt отличается от * musl ровно на один байт, и этот байт отделяет курьёз от CVE.
Здесь есть и второй урок, о тихих программных прослойках совместимости. Решение musl с мягкой * существует, чтобы сломанный софт не падал, а значит, оно мешает заметить, что он сломан. Сегфолт уродлив, но он ещё и сигнал: кто-то заводит баг, кто-то чинит вызывающий код. Проглотите ошибку, и она тихо копится десятилетие. Я всё больше убеждаюсь, что это самый глубокий разрыв в проектировании систем: инженерия, которая сопротивляется катастрофам, склонна падать громко и рано, тогда как инженерия, оптимизированная на гладкость, копит скрытый долг, пока счёт не придёт сразу.
И это должно развеять удобный миф о том, что старый, широко развёрнутый код надёжнее, потому что его проверило время. Возраст распределяет внимание неравномерно: он концентрирует проверки на горячих путях и оставляет холодные окаменевать. Все аудитируют криптографию. Почти никто не аудитирует скучный клей, где sshd разговаривает с libc о паролях, потому что этот код «работает вечно». Пересечение двух маргинальных конфигураций, то есть систем с musl и, внутри них, тех, что не используют PAM, как раз то место, где баг может спокойно жить десять лет.
Как исправить баг sshole и проверить логи
Патч самая простая часть: OpenSSH 10.6p2 закрывает дыру. В Alpine это одна команда. Если обновиться сегодня не получается, временная мера так же груба и абсолютно эффективна: отключите вход по паролю. Нет проверки пароля, нет и бага. Честно говоря, стоит сделать и то, и другое: вход только по ключам был правильным решением ещё до этого бага и останется таковым, когда все пропатчатся. Это перекликается с уроком из других споров о патчах и обходных мерах: если обходная мера дешёвая и полная, не умничайте.
# patch: openssh 10.6p2 or later
apk upgrade openssh
# can't patch yet? passwords off, keys only
echo "PasswordAuthentication no" > /etc/ssh/sshd_config.d/00-no-passwords.conf
rc-service sshd restart

Тогда возникает более трудный вопрос: успел ли кто-нибудь раньше? Десять лет это долгий срок. Вам нужны успешные аутентификации по паролю для учётных записей, у которых паролей никогда не было: пользователи деплоя только с ключами, сервисные учётные записи, идентификаторы LDAP, root на машинах Alpine. Успешные входы по password для них должны быть крайне редкими; любое совпадение это пожар уровня «пять тревог». Неприятная правда в том, что sshd логирует сам факт успеха, а не длину пароля, так что многие прямые доказательства испаряются при записи. А как мы уже писали, у логирования аутентификации в облаке есть слепые зоны даже в хороший день, и логи sshd на короткоживущих ВМ как раз там и лежат. Просмотрите то, что у вас есть. Считайте, что это неполно.
Чему должны научить десять тихих лет
Само обнаружение заслуживает не меньшего внимания, чем баг. Исследователь, нашедший sshole, не уставился в криптографию. Он посмотрел на скучную границу, где OpenSSH общается с libc, собрал sshd во всех конфигурациях, какие смог придумать (glibc, musl, PAM, без PAM, shadow, без shadow), и бросил около 17 600 некорректных логинов в эту матрицу. Странные хеши, сломанные учётные записи, слишком длинные пароли. Все сборки согласовались по каждому логину, кроме одного: musl без PAM. Эта сборка говорила «да» тому, чему говорить не должна. Это не везение, это покрытие. Дифференциальное тестирование по матрицам конфигураций единственный способ найти баги, которые прячутся на пересечениях, и это именно та исчерпывающая, неутомимая работа, в которой рои агентов оказываются поразительно хороши. Следующий sshole не получит десяти тихих лет.
Открытый вопрос, который должен не давать спать операторам: прошли ли десять лет незамеченными или что-то заметили не те люди. Подумайте хотя бы о случайном сценарии: вы вставляете не то, что нужно, из буфера обмена в поле пароля (полтред из Slack, целый приватный SSH-ключ, что угодно длиннее 257 символов), и на уязвимой машине заблокированная учётная запись просто открывается. Если вы никогда не вставляли не туда что-то в поле пароля, вы осторожнее всех, кого я знаю. Кто-то, где-то входил в машину, в которую не собирался, и тихо закрывал терминал. Возможно, таких людей были многие.
Так что патчите, да. Пароли выключите, обязательно. А потом потратьте час на поиск в своих системах символов, которые выполняют две работы: сигнальных значений, тихих запасных вариантов, веток «этого never happen», прослоек совместимости, о которых никто уже не помнит. Каждая зрелая кодовая база их содержит. Они невидимы именно потому, что никогда не вызывали проблем, которые кто-то увидел бы. Где-то в вашем стеке прямо сейчас звёздочка тихо означает две вещи одновременно. Найдите её, пока до неё не добрался кто-то с очень длинным паролем.


