Блог / Разборы CVE

Парадокс звёздочки: CVE-2026-123158 и обход аутентификации OpenSSH–musl

Две независимые друг от друга системы выбрали один и тот же символ "*", чтобы обозначить "здесь нет ничего пригодного для использования". CVE-2026-123158 показывает, как именно это совпадение обходит парольную аутентификацию OpenSSH в Linux на musl, что подтвердил -- и что не подтвердил -- лабораторный запуск с зафиксированным дайджестом образа, и какой вопрос остался открытым.

Аспирантура по информационной безопасности завершенаВнутривузовская защита пройдена · Итоговая защита диссертации впереди

Один символ, два несвязанных значения, один обход аутентификации

Сначала я отправил неверный пароль. Затем я отправил один пароль длиной 300 байт на учётную запись, для которой пароль вообще не был задан. sshd меня впустил.

Это стало возможным благодаря двум системам, которые никогда не слышали друг о друге, но обе выбрали один и тот же символ, чтобы обозначить "здесь нет ничего пригодного для использования". /etc/shadow десятилетиями использует одиночную звёздочку, чтобы отметить учётную запись, которая никогда не должна принимать пароль. Функция crypt() в musl при определённых внутренних сбоях возвращает именно этот же символ вместо нулевого значения, которое на самом деле предписывает POSIX. Ни одно из этих решений само по себе не ошибочно. Но если поместить их в одно и то же сравнение строк, sshd можно убедить, что пароль совпал, хотя на самом деле ничего не сравнивалось вовсе.

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

Эта статья сначала проходит по фону: что на самом деле делает аутентификация SSH, что хранится в /etc/shadow, что такое PAM и почему сборка без него имеет здесь значение, почему musl -- это не glibc, и что crypt() должна делать при сбое. Только после того, как это станет ясно, механизм первопричины, лабораторные свидетельства, патч и открытые вопросы действительно обретают смысл -- без того, чтобы просить читателя принять что-либо на веру.

Если вы администрируете Linux-машину на musl с включённой парольной аутентификацией SSH и без PAM, короткая версия такая: проверьте файл shadow на наличие одиночной звёздочки, проверьте, есть ли у чего-то ещё на этой машине хеш $5$ или $6$, и установите патч. Длинная версия, включающая точное объяснение того, почему это работает, и то, что реальный лабораторный запуск подтвердил, а что нет, -- это всё, что дальше.

Однодневный цикл реагирования и деталь рекомендации, которую упускает большинство публикаций

OpenSSH 10.6p2 вышел 5 ноября 2026 года с единственным пунктом по безопасности, и вышел быстро. В собственном анонсе релиза Дэмиен Миллер указывает, что исправление было выпущено на следующий день после того, как команда OpenSSH узнала о проблеме, и утверждает, что недавние случаи, когда подобные ошибки самостоятельно и независимо обнаруживались ИИ-агентами в течение нескольких часов, теперь означают, что длительный эмбарго на раскрытие в основном выгоден атакующим, а не защищающимся. Это заявленное обоснование сжатых сроков, а не предположение, которое эта статья делает от имени команды. [2]

В примечаниях к выпуску OpenSSH говорится, что sshd теперь избегает "обхода парольной аутентификации, затрагивающего некоторые системы Linux, собранные и работающие с отключённой поддержкой PAM" (PAM, Pluggable Authentication Modules -- слой, который большинство систем Linux использует для принятия решения, успешен ли вход; несколько разделов далее разбирают его подробно), и указывают, что об уязвимости сообщил Ozgun Kultekin из Wakefield. [3] Подробный технический разбор самого исследователя размещён на sshhole.com под названием "*sshole" и независимо указывает то же окно 257--1024 байта и то же совпадение символов, которое эта статья прослеживает полностью далее. [4] Имя пользователя сайта на GitHub, oz9un, согласуется с указанным именем, хотя ничто в записи явно не подтверждает эту связь; это не является опорой ни для одного технического утверждения здесь -- просто стоит упомянуть прямо, а не утверждать как факт.

Собственная рекомендация Alpine Linux -- второй первичный источник, на который опирается вся эта статья. Она точно описывает состояние уязвимой учётной записи: поле shadow, буквально равное "*", и прямо отмечает, что заблокированная учётная запись, у которой это поле равно "!", не затронута. Она формулирует общее предварительное условие как необходимость наличия хотя бы одной другой локальной учётной записи с настоящим хешем пароля на той же машине -- формат SHA-256/SHA-512 по умолчанию в Alpine, показанный в таблице чуть ниже, -- говорит, что одной такой учётной записи достаточно, чтобы сделать достижимой любую учётную запись без пароля, и описывает сам приём как требующий одного соединения и не требующий повторных попыток -- фраза, к которой эта статья прямо возвращается позже. [5]

Сам CVE-2026-123158 несёт базовую оценку CVSS 3.1 8.1 (высокая), вектор AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H, и CWE-394, "Unexpected Status Code or Return Value" (неожиданный код состояния или возвращаемое значение), что является правильной классификацией для ошибки, которая в своей основе заключается в доверии сигнализирующему об ошибке значению без его проверки. Собственное дополнение CISA к этой же записи отдельно оценивает зрелость эксплуатации как "poc" и автоматизируемость как "нет". На момент написания об эксплуатации в реальных условиях не сообщалось. [1]

Большинство вторичных публикаций об этом CVE упрощают его до "чрезмерно длинный пароль обходит SSH". Сама рекомендация точнее почти по трём пунктам, которые большинство этих публикаций упускают: учётная запись должна быть именно в состоянии "*", а не "!"; что-то ещё на той же машине уже должно иметь пригодный хеш; и окно байтов -- 257--1024, а не просто "длинный" в общем смысле. Остальная часть этой статьи построена именно вокруг этих трёх деталей, плюс той, которую рекомендация формулирует уверенно, но которую практический лабораторный запуск имеет основания проверить, а не принимать на веру.

Что на самом деле происходит, когда вы вводите пароль SSH

SSH существует для того, чтобы одна машина могла администрировать другую по сети, которой ни одна из сторон не обязана доверять, -- в зашифрованном виде и, в идеале, с доказательством того, что обе стороны являются тем, за кого себя выдают. Клиент подключается к sshd, серверному демону; стороны сначала договариваются о защищённом канале, и только после того, как этот канал установлен, происходит аутентификация -- по открытому ключу, по паролю или другим настроенным способом, -- прежде чем запустится любая оболочка или команда. Парольная аутентификация, метод, которого касается весь этот CVE, -- это один конкретный шаг внутри этого более широкого квитирования, а не отдельный механизм, прикрученный сбоку.

В системе Linux информация об учётных записях хранится в /etc/passwd, по одной строке на учётную запись: имя пользователя, числовой идентификатор пользователя, домашний каталог, оболочка. Сам пароль, точнее его хеш, десятилетия назад тоже хранился там. Теперь он хранится в отдельном файле, /etc/shadow, который может читать только root, -- именно для того, чтобы компрометация обычной пользовательской учётной записи не выдавала попутно все хеши паролей на машине.

Однако это поле shadow не всегда содержит хеш. Оно может содержать небольшое число специальных значений, которые администраторы, инструменты и сам sshd должны правильно интерпретировать -- они перечислены ниже.

По замыслу, учётная запись, отмеченная одиночной звёздочкой, не должна проходить аутентификацию по паролю при любом вводе вообще, потому что "*" вовсе не является форматом хеша. Это соглашение: такая учётная запись существует, но никакой пароль никогда её не разблокирует. Именно на это соглашение опираются администраторы для учётных записей, доступных только по ключу, служебных идентичностей и базовых образов контейнеров, в которых есть учётная запись root, в которую никто не ожидает входа по паролю. Вся уязвимость, которую прослеживает эта статья, -- это случай, когда это соглашение тихо перестало выполняться.

Что означает каждое значение поля пароля в /etc/shadow и достижимо ли оно через этот обход.
Поле пароляЗначениеЗатронуто
$1$... / $2a$/$2b$/$2y$... / $5$... / $6$...crypt() работает как обычно и вычисляет настоящий хеш для сравнения.Настоящий хеш пароля (MD5, bcrypt, SHA-256 или SHA-512)Нет
! или !...Рекомендация Alpine прямо указывает на это.Учётная запись заблокированаНет
*При точных условиях, описанных в этой статье.Пароль не задан (учётная запись только для ключей)Да
(пустая строка)Обрабатывается отдельной, более ранней веткой кода в sys_auth_passwd(), до пути, который рассматривает этот CVE.Пароль не требуетсяНет

PAM и почему сборка sshd без него меняет всё

PAM, Pluggable Authentication Modules, -- это слой, который большинство дистрибутивов Linux использует для принятия решения, удастся ли попытка входа, включая SSH. Приложение вроде sshd обычно не реализует проверку пароля само; оно обращается к PAM, а PAM запускает те модули, которые администратор настроил для этой службы, -- pam_unix для локальных паролей в типичном случае, с другими модулями для служб каталогов или схем многофакторной аутентификации. Эта косвенность -- поведение по умолчанию в дистрибутивах на glibc, таких как Debian, Ubuntu, Fedora и RHEL: в этих системах sshd вообще никогда не вызывает функцию хеширования пароля напрямую. [2]

Alpine Linux и большинство встраиваемых сборок на musl (OpenWrt, Buildroot, Yocto) вместо этого собирают OpenSSH с флагом --without-pam. В таких системах нет слоя PAM, которому можно было бы передать решение. sshd откатывается к собственному общему коду сравнения в auth-passwd.c, вызывая crypt(3) напрямую против собственной записи учётной записи в shadow. Это единственное решение, принятое на этапе сборки, -- та самая развилка, на одной из веток которой целиком и живёт этот CVE: система с PAM вообще никогда не достигает уязвимого сравнения, и не потому, что какая-то конкретная защита этому препятствует, -- этот участок кода просто никогда не выполняется в такой сборке. [2]

Ещё одна деталь, которую стоит знать перед следующими двумя разделами, потому что раздел о первопричине напрямую на неё опирается: для имени пользователя, которое sshd считает недействительным, путь без PAM всё равно выполняет сравнение -- против закешированного фиктивного пароля, -- чтобы отказ для несуществующей учётной записи занимал примерно столько же времени, сколько отказ из-за неверного пароля для реальной учётной записи. Это намеренная, давно существующая защита по времени от перечисления имён пользователей, и она использует тот же самый код выбора соли, который направляет учётную запись "*" в неверную ветку. "Код выбора соли" здесь означает просто логику, которая решает, с каким эталонным значением crypt() сравнивает пароль; что именно представляет собой это эталонное значение и как оно ошибается -- к этому возвращаются разделы о механизме ниже.

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

01sshd получает запрос на парольную аутентификацию
Сборка с PAMдистрибутивы на glibc: Debian, Ubuntu, Fedora, RHEL
02Передаётся стеку PAMобрабатывается pam_unix или аналогичным модулем
03PAM принимает или отклоняетsshd никогда не вызывает crypt() напрямую
Сборка без PAM--without-pam: Alpine, встраиваемые сборки на musl
02auth-passwd.c выполняет собственное сравнение sshdслоя PAM, которому можно было бы передать решение, не существует
03Сравнение через crypt() с сохранённым хешемименно этот путь и содержит данный CVE
Код проверки пароля в sshd расходится полностью ещё на этапе сборки: сборка с PAM передаёт решение PAM, а сборка без PAM сама выполняет сравнение -- вплоть до одного конкретного уязвимого шага.

musl, glibc и почему эта ошибка не затрагивает Debian

libc, стандартная библиотека C, -- это слой между программой и ядром операционной системы под ней: выделение памяти, работа со строками и, для этой истории, crypt(). Каждая программа Linux, написанная на C, линкуется с какой-либо из них. Большинство настольных и серверных дистрибутивов используют glibc, библиотеку GNU C. Alpine Linux вместо этого использует musl -- меньшую, независимо написанную реализацию того же набора стандартных интерфейсов.

musl и glibc не взаимозаменяемы на уровне реализации во всём, что они предоставляют. Они реализуют одни и те же стандартные интерфейсы, включая crypt(), но не всегда с идентичным внутренним поведением за этим интерфейсом. Этот разрыв обычно невидим для прикладного кода, вызывающего его через интерфейс. Здесь он перестал быть невидимым.

Alpine поставляет OpenSSH, собранный с musl. OpenSSH вызывает стандартный интерфейс crypt(3) точно так же, как и с любой другой libc. Собственная реализация этого интерфейса в musl -- конкретно её пути кода для SHA-256 и SHA-512 -- это именно то место, где живёт конкретное поведение при сбое, которое прослеживают следующие три раздела, а не в коде самого OpenSSH и не в упаковке Alpine.

Здесь стоит быть точным, потому что легко преувеличить: уязвимость требует именно musl, а не просто "собрано без PAM". Код сравнения OpenSSH без PAM существует в любой сборке, скомпилированной таким образом, включая, гипотетически, нестандартную сборку на glibc. Но поведение при сбое, с которым сталкивается этот CVE, -- crypt(), возвращающая однобайтовый признак сбоя вместо NULL, -- это собственное решение реализации musl. Это расследование не нашло эквивалентного поведения в glibc, и собственный анонс релиза OpenSSH явно указывает, что затронутая совокупность -- это Linux, musl и отсутствие PAM, а не не-Linux, не glibc, не системы с PAM. [2]

Стек из пяти уровней, сверху вниз: Alpine Linux как дистрибутив, sshd из OpenSSH, собранный без PAM, библиотечный интерфейс crypt(3), собственная реализация crypt() в musl libc и, в самом низу, код хеширования для конкретных алгоритмов. Каждый уровень связан с соседним конкретным вызовом или соглашением, названным между ними. Ни один уровень не отмечен как опасный: это расследование не проверяло самостоятельно поведение при сбое на уровне исходного кода crypt_sha256.c/crypt_sha512.c в musl.

Alpine Linux (дистрибутив)Поставляет OpenSSH, собранный с musl, а не с glibc
Сборка пакета apk: OpenSSH скомпилирован с musl
sshd из OpenSSH, собранный с --without-pam
Вызов библиотеки C: crypt(attacker_password, stored_hash)
Интерфейс libc: crypt(3)
Тонкий диспетчер: статический буфер, __crypt_r()
Собственная реализация crypt() в musl libc
Диспетчеризация по префиксу соли ($6$, $5$, ...)
Код хеширования для конкретных алгоритмов: crypt_sha256.c / crypt_sha512.c
Где musl находится между кодом аутентификации OpenSSH и реальной реализацией хеширования паролей.

Что crypt() должна делать при сбое

crypt(3) -- это традиционный интерфейс Unix для вычисления и проверки того самого одностороннего хеша (не обратимого шифрования) -- хранимого значения shadow, с которым вы уже встречались два раздела назад. crypt(password, salt) вычисляет хеш, и эта же функция повторно используется для проверки попытки входа -- путём повторного вычисления хеша из введённого пароля и собственного хранимого хеша учётной записи, повторно используемого как соль, а затем сравнения двух результатов обычным сравнением строк. Соль существует, чтобы две учётные записи с одинаковым паролем не получили в итоге одинаковый хранимый хеш.

POSIX определяет, что crypt() должна делать, если что-то идёт не так внутри во время этого вычисления: вернуть нулевой указатель и установить errno, чтобы указать причину. Вызывающий код, который проверяет наличие NULL, знает, что вычисление не удалось, и никогда не должен рассматривать результат как действительный хеш для сравнения с чем-либо ещё.

crypt() в musl не всегда следует этому контракту. Что она делает вместо этого и почему именно это отклонение сталкивается с собственными соглашениями /etc/shadow из предыдущего раздела -- это тот механизм, который остальная часть этой статьи прослеживает на уровне исходного кода, начиная отсюда.

Почему действительная учётная запись направляется по запасному пути выбора соли

Вот первая неочевидная деталь, и это однобайтовая логическая ошибка, а не что-то драматичное. Собственная проверка выбора соли в sys_auth_passwd() у OpenSSH не спрашивает, является ли хранимый хеш учётной записи заглушкой "*". Она спрашивает, составляет ли длина этого хеша хотя бы два символа.

Защитное условие -- `if (authctxt->valid && pw_password[0] && pw_password[1]) salt = pw_password; else salt = NULL;`. Для однобайтовой строки "*" pw_password[0] -- это сама звёздочка, что истинно, но pw_password[1] -- это завершающий нуль-символ строки, что в C ложно. Один истинный символ. Один ложный терминатор. Это всё условие, и оно уже неверно для этого случая. Оно вычисляется как false даже при том, что authctxt->valid истинно, то есть даже для совершенно реальной, существующей учётной записи. Код падает в ту же ветку salt = NULL, что используется для имени пользователя, которого не существует вовсе, не потому что что-то конкретно проверяет на "*", а как побочный эффект проверки длины, которая по совпадению читается как "достаточно ли это длинно, чтобы правдоподобно быть хешем". [6]

Когда salt равна NULL, xcrypt() вызывает pick_salt(), полностью воспроизведённую ниже с сохранённым оригинальным уведомлением о лицензии BSD. pick_salt() хранит статический 32-байтовый буфер, ограниченный одним процессом, и немедленно возвращает его, если он уже заполнен. При первом вызове в рамках процесса она проходит по всем локальным учётным записям через setpwent()/getpwent() и копирует префикс, определяющий алгоритм, из первого найденного хеша, который начинается с "$" и содержит второй "$", то есть любого хеша в модульном crypt-формате: MD5 ($1$), bcrypt ($2a$/$2b$/$2y$), SHA-256 ($5$) или SHA-512 ($6$) -- не какого-то одного конкретного из них. Если ничто в системе не имеет пригодного хеша, она откатывается к традиционной соли в стиле DES, "xx". [7]

Рекомендация Alpine формулирует предварительное условие как необходимость другой учётной записи именно с хешем $5$ или $6$. Сам код шире этого: он принимает первый хеш в модульном crypt-формате в том порядке, в котором getpwent() случайно перечисляет учётные записи, что также охватывает MD5 и bcrypt. Этот разрыв между формулировкой рекомендации и реальным поведением кода -- собственная интерпретация этого расследования, а не то, что прямо утверждает какой-либо из источников, но стоит отметить: машина, использующая в других местах только хеши MD5 или bcrypt, всё равно будет удовлетворять реальному предварительному условию, даже если формулировка Alpine может наводить на иную мысль.

Ещё одна граница, и она собственная для OpenSSH, не связанная с musl: auth_password() отклоняет любой пароль длиннее MAX_PASSWORD_LEN, определённого как 1024, прежде чем sys_auth_passwd(), а значит и crypt(), вообще будет достигнута. Именно поэтому окно рекомендации ограничивается 1024 байтами, а не остаётся открытым; более длинный пароль вообще никогда не подходит близко к уязвимому сравнению. [8]

/* auth-passwd.c, sys_auth_passwd() -- pre-fix */
char *pw_password = authctxt->valid ?
    shadow_pw(pw) : xstrdup(pw->pw_passwd);
...
if (authctxt->valid && pw_password[0] && pw_password[1])
    salt = pw_password;
else
    salt = NULL;
encrypted_password = xcrypt(password, salt);
...
ret = strcmp(encrypted_password, pw_password) == 0;

/* openbsd-compat/xcrypt.c -- Copyright (c) 2003 Ben Lindstrom, BSD license */
static const char *
pick_salt(void)
{
    struct passwd *pw;
    char *passwd = NULL, *p;
    size_t typelen;
    static char salt[32];

    if (salt[0] != '\0')
        return salt;
    strlcpy(salt, "xx", sizeof(salt));
    setpwent();
    while ((pw = getpwent()) != NULL) {
        if (passwd != NULL)
            freezero(passwd, strlen(passwd));
        if ((passwd = shadow_pw(pw)) == NULL)
            continue;
        if (passwd[0] == '$' && (p = strrchr(passwd+1, '$')) != NULL) {
            typelen = p - passwd + 1;
            if (passwd[1] == '2' && strlen(p + 1) >= 22)
                typelen += 1 + 22;
            strlcpy(salt, passwd, MINIMUM(typelen, sizeof(salt)));
            explicit_bzero(passwd, strlen(passwd));
            goto out;
        }
    }
 out:
    if (passwd != NULL)
        freezero(passwd, strlen(passwd));
    endpwent();
    return salt;
}

Где "*" из /etc/shadow встречает собственную "*" musl

В этой точке кода sshd верит в одну конкретную вещь: строка, которую crypt() возвращает для настоящего пароля, никогда не может быть равна собственному хешу-заглушке учётной записи. Атакующий контролирует ровно тот один ввод, который разрушает это убеждение: длину своего пароля.

Рекомендация Alpine прямо описывает состояние уязвимой учётной записи: поле пароля shadow, буквально равное "*", -- соглашение для учётной записи без пригодного хеша пароля, учётной записи только для ключа. Явно указано, что заблокированная учётная запись, поле "!", не затронута. [5]

crypt() в musl, в её путях хеширования SHA-256 и SHA-512, возвращает буквальную строку "*" -- строковую константу, хранящуюся в памяти только для чтения, -- вместо NULL, когда внутренняя проверка хеша не удаётся. Это именно то нарушение контракта, которое описал предыдущий раздел, теперь вызванное одним конкретным условием: чрезмерно длинным введённым паролем. Точная проверка для каждого алгоритма внутри crypt_sha256.c и crypt_sha512.c не была самостоятельно прочитана в рамках этого расследования; то, что документировано здесь, ограничено рекомендацией Alpine, собственным разбором сообщившего исследователя и обсуждением в списке рассылки musl за декабрь 2024 года, описывающим идентичное поведение "*" при сбое в совершенно другом контексте, почти за два года до появления этого CVE. [5] [4] [9] Собственный crypt.c в musl -- это тонкий диспетчер, который сам не выполняет никакой из этих проверок, что подтверждено прямым осмотром файла. [10]

Сбой, который производит "*", вызывается чрезмерно длинным введённым паролем. Рекомендация Alpine и запись CVE указывают одно и то же окно: 257--1024 байта. Нижняя граница -- собственный внутренний порог musl; верхняя граница, как было рассмотрено два раздела назад, -- это уже существовавшее ограничение MAX_PASSWORD_LEN у OpenSSH, вообще не связанное с musl. [5] [1]

Сложим это вместе: атакующий, который отправляет пароль в этом диапазоне байтов против учётной записи, чей хранимый хеш -- "*", заставляет crypt() вычислить и вернуть "*". Следующее сравнение -- `strcmp("*", "*")`, которое по определению истинно, потому что обе стороны теперь -- в точности одна и та же однобайтовая строка. Аутентификация проходит для учётной записи, явно настроенной никогда не принимать пароль.

Ни одна из двух половин этого не выглядит ошибочной по отдельности, и именно поэтому это так легко пропустить. Системы типов здесь не помогают: обе стороны сравнения -- char*, и "*" -- синтаксически обычная, успешно возвращённая строка как со стороны чтения shadow, так и со стороны crypt(). Для статического анализатора здесь ничто не выглядит как условие ошибки. И PAM поймала бы это поведение случайно, через более серьёзную ошибку: в системе с PAM то же самое возвращаемое значение musl вместо этого обрушивает pam_unix, потому что pam_overwrite_string() пытается записать в тот строковый литерал только для чтения, -- это отказ в обслуживании, а не обход, и проблема, уже независимо сообщённая в декабре 2024 года. У систем без PAM нет эквивалентного промежуточного вызова, поэтому то же самое базовое поведение musl приводит к тихому успеху аутентификации вместо аварийного завершения. [9]

/* musl libc, src/crypt/crypt.c -- MIT license */
char *crypt(const char *key, const char *salt)
{
    static char buf[128];
    return __crypt_r(key, salt, (struct crypt_data *)buf);
}
/* a thin dispatcher only -- the overlong-key / "*"-on-failure behavior
   lives inside crypt_sha256.c / crypt_sha512.c, not here. That specific
   file was not independently read in this investigation; see the text. */

Две стороны, каждая отмечена символом звёздочки: слева -- соглашение /etc/shadow, по которому одиночная звёздочка означает, что пароль никогда не будет действителен; справа -- признак сбоя в crypt() из musl, который при определённых сбоях также возвращает буквальную звёздочку. Обе стороны сходятся в выделенной точке с записью "strcmp звёздочки и звёздочки равно нулю" и вердиктом "совпадение: аутентификация успешна".

Собственное соглашение /etc/shadow

Одиночная звёздочка означает: пароль никогда не будет действителен.

Признак сбоя crypt() из musl

При сбое возвращает тот же символ вместо NULL.

strcmp("*", "*") == 0

СОВПАДЕНИЕ: АУТЕНТИФИКАЦИЯ УСПЕШНА

Два не связанных друг с другом соглашения, оба обозначенные одной звёздочкой, сходятся в одном и том же сравнении.

Что было на самом деле собрано, зафиксировано и запущено

Всё в следующих двух разделах взято из одного реального запуска, а не мысленного эксперимента: образ Alpine 3.24.4 с зафиксированным дайджестом, собранный из изменённой копии публичного репозитория proof-of-concept, выполненный 7 ноября 2026 года и независимо повторно проверенный по собственным зафиксированным по хешу свидетельствам до того, как было написано что-либо из нижеследующего.

Базовым образом был alpine:3.24, зафиксированный на его дайджесте манифеста linux/amd64, а не доверенный как плавающий тег. Здесь ничто не было оставлено на волю плавающего тега. Сам лабораторный образ был намеренно собран с OpenSSH 10.6p1, скомпилированным с --without-pam: предпатчевой, уязвимой версией, а не исправленной 10.6p2. Версии musl, libcrypto3, shadow и zlib были зафиксированы напрямую из собственных метаданных пакетов собранного образа, а не приблизительно оценены по актуальному индексу веток.

Записи shadow для root, alice, alpine и victim были установлены в "*"; bob получил настоящий, детерминированный хеш SHA-512, построенный с фиксированной солью, переданной через файл, а не через аргумент командной строки, как намеренный отрицательный контрольный тест. sshd_config установил PasswordAuthentication yes и, намеренно изменённый относительно значения по умолчанию публичного proof-of-concept, PermitRootLogin no, поскольку root никогда не входил в утверждённую область тестирования этой лаборатории.

Изоляция была строже, а не слабее настроек Docker по умолчанию: контейнер работал в сети bridge, настроенной как --internal, без маршрута по умолчанию наружу, Privileged false, CapDrop ALL, и были добавлены обратно только CAP_NET_BIND_SERVICE, CAP_SETGID, CAP_SETUID и CAP_SYS_CHROOT, каждый обоснованный против собственного исходного кода разделения привилегий OpenSSH 10.6p1, а не принятый на веру, плюс no-new-privileges. Ни пароль, ни секрет не появлялись в командной строке какого-либо процесса; каждая фикстура считывалась из файла.

Только alice и bob были частью утверждённого плана тестирования. То, что эта граница на самом деле соблюла, а что не соблюла, -- это отдельный раздел, помещённый прямо после результатов, а не тихо вплетённый в них.

Base image     alpine:3.24, pinned to linux/amd64 digest
               sha256:0f49f331d9212aff11b932863be4eb01cdf962969
               aa86dab7d1ee21ac6f5f52b  (resolves to Alpine 3.24.4)
Lab image      sshole-lab:revision3
               sha256:b973cd33dfd98a767b93cd5f95987d817f7b8eeda0
               6e804e173b431ae6540e45
OpenSSH        10.6p1, built --without-pam (the pre-fix version,
               deliberately -- this run tests the vulnerable build)
musl           1.2.6-r3
Capabilities   --cap-drop=ALL
               --cap-add=NET_BIND_SERVICE --cap-add=SETUID
               --cap-add=SETGID --cap-add=SYS_CHROOT
               --security-opt=no-new-privileges
Network        sshole-lab-net, --internal (no default route out)
sshd_config    PasswordAuthentication yes
               PermitRootLogin no

Случай A провалился. Случай B удался. Bob не сдвинулся ни в одну сторону.

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

Случай A проверял одну, не предварённую чрезмерно длинную попытку пароля: 300 байт, отправленных как первый и единственный запрос парольной аутентификации нового соединения против alice, чьё поле shadow равно "*". Он не удался. Лог сервера показывает одно соединение, одну строку "Failed password for alice" и закрытие соединения. Нигде для этого соединения не появляется строка "Accepted". Это зафиксировано в досье как не воспроизведённое.

Случай B проверял тот же пароль, предварённый: сначала короткий, намеренно неверный пароль, сразу за которым следовал тот же 300-байтовый чрезмерно длинный пароль, как два последовательных запроса аутентификации внутри одного SSH-соединения против alice. Он удался. Лог сервера показывает "Failed password for alice ... port 33394 ssh2", сразу за которым следует "Accepted password for alice ... port 33394 ssh2", оба на идентичном исходном порту, от начала до конца, одно соединение. Протокол клиента подтвердил ожидаемый маркер выполнения команды от имени alice. Это зафиксировано как воспроизведённое.

Случаи C1 и C2 использовали bob, контроль с настоящим хешем. C1, настоящий пароль bob, удался как проверка позитивного контроля. C2, тот же самый 300-байтовый чрезмерно длинный пароль, который только что сработал против alice, не удался против bob: "Accepted password for bob ... ssh2" для правильного пароля, "Failed password for bob ... ssh2" для чрезмерно длинного. Вместе они подтверждают, что сам по себе чрезмерно длинный пароль не обходит аутентификацию в общем случае; он совпадает только с вырожденной заглушкой "*", именно как предсказывает описанный выше механизм первопричины.

Эти четыре результата -- одна экспериментальная точка из одной конкретной сборки с зафиксированным дайджестом, запущенной однократно, 7 ноября 2026 года. Это не утверждение о том, как CVE-2026-123158 ведёт себя на каждой комбинации musl и OpenSSH везде; это то, что именно этот образ, именно эта сборка OpenSSH 10.6p1 и именно эти вводы действительно сделали в этом одном запуске. Досье намеренно фиксирует их с этой областью применения, и эта статья сохраняет ту же формулировку, а не обобщает её.

Case A -- single-shot, alice, no priming -- not reproduced
  Failed password for alice ... ssh2
  [connection closes; no Accepted line for this connection]

Case B -- primed (short wrong, then overlong, one connection), alice -- reproduced
  Failed password for alice ... port 33394 ssh2
  Accepted password for alice ... port 33394 ssh2

Case C1 -- bob, real password, positive control -- reproduced
  Accepted password for bob ... ssh2

Case C2 -- bob, same overlong password, negative control -- reproduced
  Failed password for bob ... ssh2

Таблица из четырёх тестовых случаев одного лабораторного запуска. Случай A -- одна, не "подготовленная" заранее попытка с чрезмерно длинным паролем против alice -- в логе зафиксирован неудачный пароль и закрытое соединение: не воспроизведено. Случай B -- тот же пароль, но предварённый короткой неверной попыткой -- в логе зафиксирован неудачный пароль, за которым сразу следует принятый пароль: воспроизведено. Случай C1 -- настоящий пароль bob -- в логе зафиксирован принятый пароль как базовый случай. Случай C2 -- тот же чрезмерно длинный пароль против bob -- в логе зафиксирован неудачный пароль как отрицательный контроль. Отдельно отслеживаемый открытый вопрос отмечает, что причина, по которой предварение имело значение в этом запуске, до сих пор не выяснена на уровне исходного кода.

СлучайПопыткаРезультат в логеИнтерпретация
Случай AОдна попытка, без предваренияFailed password → соединение закрытоНе воспроизведено
Случай BС предварением: неверный, затем чрезмерно длинныйFailed password → Accepted passwordВоспроизведено
Случай C1Настоящий пароль bobAccepted passwordВоспроизведено (базовый случай)
Случай C2Тот же чрезмерно длинный пароль против bobFailed passwordВоспроизведено (отрицательный контроль)
Открытый вопрос (C23)

Почему предварение имело значение в этом запуске, до сих пор не выяснено на уровне исходного кода: pick_salt() синхронно возвращает соль при любом вызове, и в самой её логике нет ничего, что явно требовало бы предварительного вызова.

Четыре результата лабораторного запуска с зафиксированным дайджестом образа, записанные языком собственных логов сервера, а не в виде таблицы "прошло/не прошло".

Чего этот запуск не доказывает, и попытки против root, о которых никто не сообщил

Лабораторный запуск настолько достоверен, насколько честно он признаёт, что он не проверил. Этот запуск прямо признаёт три конкретные вещи, а не позволяет чистому набору результатов в рамках области применения выступать заменой чистого процесса.

Первое -- это находка в области применения, а не в механизме. Тот же файл свидетельств, который зафиксировал однократный тест случая A против alice, также содержит, раньше в том же окне захвата, нераскрытую последовательность против root: одну строку "Failed none for root", затем две последовательные строки "Failed password for root ... ssh2" внутри одного соединения, затем закрытие соединения. Нигде не появляется строка "Accepted" для root. Отправленное значение за этими двумя попытками неизвестно, и этот отчёт не будет его угадывать. root никогда не входил в утверждённую область этой лаборатории, только alice и bob, на каждом уровне авторизации этой задачи, и собственная сводка оператора запуска о том, что было протестировано, никогда об этом не упоминала.

Ничего не было скомпрометировано: обе попытки против root провалились, и PermitRootLogin no было подтверждено в собственной действующей конфигурации демона для этого запуска. Отклонение от области применения, которое всё равно проваливается, остаётся отклонением от области применения. Именно поэтому статус лабораторного запуска в этом досье зафиксирован как "частичный", а не "воспроизведённый", -- различие, которое стоит сохранять, даже там, где свидетельства в рамках области применения в остальном выглядят чисто. Почему эта последовательность произошла вообще -- был ли вместо запланированных, ограниченных по области пользовательских команд запущен более широкий список имён пользователей по умолчанию, встроенный в инструментарий, или что-то совершенно иное -- это вопрос, на который должен ответить непосредственно тот, кто проводил лабораторный запуск, а не предмет для спекуляций здесь.

Второй разрыв -- это ограничение измерения, а не области применения. Тест случая B запустил параллельный цикл опроса идентификаторов процессов специально для того, чтобы зафиксировать прямое свидетельство того, обслуживал ли один и тот же процесс ОС как попытку предварения, так и чрезмерно длинную попытку, -- свидетельство, которое напрямую относилось бы к открытому вопросу в следующем разделе. Он собрал ровно один пригодный к использованию образец в течение обмена. Это согласуется с непрерывностью одного процесса на протяжении обеих попыток, но не является непрерывным сравнением "до и после" через промежуток между ними. Наводит на мысль. Не доказывает.

Третий разрыв касается собственного сетевого пути лаборатории, а не самой уязвимости. План указывал публикацию лаборатории на 127.0.0.1:8222; оператор запуска сообщает, что эта привязка не обеспечила рабочую связность с хостом после того, как контейнер также был подключён к сети --internal без выхода вовне, и вместо этого использовался второй, одноразовый контейнер тестового клиента в той же внутренней сети, достигая лабораторию по её DNS-имени контейнера. Собственный вывод инспекции контейнера всё же показывает, что запрошенная привязка порта принята в HostConfig.PortBindings, в то время как NetworkSettings.Ports того же файла, который фиксирует то, что Docker фактически установил во время выполнения, а не то, что было просто запрошено, читается пустым: `{"22/tcp": []}`. Это подтверждает отчёт. Это не доказывает его окончательно. Никто в этом запуске фактически не пытался подключиться к 127.0.0.1:8222 извне контейнера, чтобы убедиться, что он действительно не был активен.

Ничто из этого не меняет того, что показали случаи A, B, C1 или C2. Это меняет то, насколько этот один запуск может служить прецедентом для следующего, и честный ответ -- меньше, чем дал бы полностью соответствующий области применения запуск, особенно по пункту об учётной записи root.

Исправление не трогает musl. Оно добавляет одно условие к одному сравнению.

Исправляющий коммит, 4353fdc43ef646c43a8dda7025cc1a898caf1482, "avoid password authn bypass", автором которого является Дэмиен Миллер, от 5 ноября 2026 года, не трогает musl, не трогает pick_salt() и не трогает поведение с чрезмерно длинным паролем, которое запускает всё это. [11]

Он добавляет `#define MIN_PASSWORD_HASH_LEN 4` рядом с существующим MAX_PASSWORD_LEN в auth-passwd.c и меняет итоговое решение об аутентификации так, чтобы требовать `strlen(pw_password) >= MIN_PASSWORD_HASH_LEN` в дополнение к неизменённой проверке strcmp(), где pw_password -- это собственный хранимый хеш учётной записи, никогда не та сторона сравнения, на которую влияет атакующий. [11]

Поскольку "*" и "!" обе ровно по одному символу, `strlen(pw_password) >= 4` ложно для каждой учётной записи, которой эта ошибка вообще могла бы достичь, и `&&` замыкается раньше, чем strcmp() вообще выполнится, независимо от того, что crypt() возвращает для любого ввода, длинного или короткого.

То же самое изменение владения памятью -- копия в куче через xstrdup() и freezero() вместо указателя, которым вызывающий код не владеет, -- было применено во всех auth-passwd.c, openbsd-compat/port-uw.c (эквивалентном пути, специфичном для UnixWare) и pick_salt()/shadow_pw() в xcrypt.c. Однако сама новая проверка длины попала только в общий путь auth-passwd.c, а не в отдельное сравнение в port-uw.c. UnixWare не является целью musl, поэтому это конкретное столкновение не может произойти через этот путь, что, возможно, и является причиной, по которой проверка там не считалась необходимой; это собственная допустимая интерпретация этого расследования, а не то, что прямо утверждает коммит. [11]

Само сообщение коммита утверждает, что обход "затрагивает некоторые конфигурации и конфигурации времени выполнения" и что команда "не считает первопричину находящейся внутри OpenSSH", будучи при этом "хорошо оснащённой, чтобы избежать этого". Эта формулировка соответствует тому, что это исправление на самом деле делает: оно блокирует именно то столкновение, описанное выше, -- однобайтовый признак, сталкивающийся с однобайтовой заглушкой shadow, -- устанавливая нижнюю границу длины хранимого хеша, а не проверяя, что вычисленный хеш корректно оформлен для заявленного алгоритма. Если бы musl, или любое ПО, совместимое с musl libc, через которое оно линкуется, когда-либо вернуло какую-то другую короткую, не являющуюся заглушкой строку на другом пути сбоя, длиной четыре символа или более, эта конкретная проверка не поймала бы это. Это расследование не нашло такого пути, существующего в настоящее время; это ограничение того, что гарантирует исправление, а не известная остаточная ошибка. [11]

/* auth-passwd.c, commit 4353fdc -- post-fix */
#define MIN_PASSWORD_HASH_LEN 4
...
ret = encrypted_password != NULL &&
    strlen(pw_password) >= MIN_PASSWORD_HASH_LEN &&
    strcmp(encrypted_password, pw_password) == 0;

Alpine не перевела все ветки сразу на 10.6p2

Alpine не перевела все поддерживаемые ветки на OpenSSH 10.6p2. Она перенесла исправление обратно в ту версию, которая уже работала в каждой ветке, что является стандартной практикой дистрибутивов: сохранить существующую версию пакета ветки, исправить конкретный дефект.

Трекер безопасности Alpine перечисляет семь исправленных версий, специфичных для веток, а не одно число для всего парка. Каждая из поддерживаемых на данный момент веток получила перенесённое исправление. [12]

Если вы проверяете парк машин, сверяйте ветку с таблицей ниже, а не ищите буквальную строку "10.6p2" повсюду. Машина на ветке 3.21-main с перенесённым исправлением 9.9_p2-r1 исправлена, хотя `sshd -V` никогда не выведет 10.6.

Собственные версии пакетов, в которых Alpine исправила OpenSSH для каждой ветки.
ВеткаВерсия пакета с исправлением
edge-main10.6_p2-r0
3.24-main10.3_p1-r2
3.23-main10.2_p1-r1
3.22-main10.0_p1-r11
3.21-main9.9_p2-r1
3.20-main9.7_p1-r6
3.19-main9.6_p1-r3

Что проверить в ваших логах прямо сейчас

Собственная рекомендация Alpine даёт руководство по обнаружению и устранению, достаточно конкретное, чтобы действовать сегодня вечером, а не общий совет "проверьте свои логи".

Логика обнаружения такова: определите каждую учётную запись, чьё поле пароля в shadow буквально равно "*", затем поищите в собственных логах демона успешный вход по паролю именно для этих учётных записей -- строка "Accepted password for" является маркером для поиска. Наряду с этим проверьте использование doas, недавно созданных пользователей, новые файлы и изменения authorized_keys. Если вход по паролю против учётной записи без пароля в окне подверженности не может быть исключён, собственная позиция рекомендации -- считать машину скомпрометированной. [5]

Устранение -- это либо обновление пакета с последующим перезапуском демона, поскольку одно только обновление пакета не заменяет уже работающий процесс, либо полное отключение парольной аутентификации как временная мера, проверенная до того, как перезапуск вступит в силу. [5]

Один нюанс, специфичный для root, стоит сформулировать точно: значение по умолчанию у Alpine, PermitRootLogin prohibit-password, уже защищает root от этого обхода, если только администратор явно не установил PermitRootLogin yes, даже хотя сам root поставляется как root:* в базовом образе. Однако это значение по умолчанию не покрывает каждый путь к root: любая другая учётная запись, способная входить по паролю и повышать привилегии, например через doas, всё равно создаёт реальную подверженность риску, даже пока собственный прямой вход root по паролю остаётся защищённым. [5]

# Package upgrade, then restart -- upgrading alone does not
# replace the already-running daemon
apk upgrade -U -a
rc-service sshd restart

# Or: disable password authentication entirely
#   PasswordAuthentication no   (in sshd_config)
sshd -t
rc-service sshd restart

# Detection: look for a successful password login on an account
# that should never have one
grep "Accepted password for" /var/log/messages
# then cross-reference each username against /etc/shadow for a
# password field that is exactly "*"

Почему один и тот же пароль провалился при первой попытке и сработал при второй?

Почему точно такой же чрезмерно длинный пароль провалился в первый раз и удался во второй, против одной и той же учётной записи, в одном и том же запуске?

Собственная рекомендация Alpine утверждает, что атака "занимает одно соединение... и не требует повторных попыток". Оба независимо написанных скрипта proof-of-concept, рассмотренные для этого досье, -- тот, что был предоставлен для сборки лаборатории, и отдельный, на который прямо ссылаются собственные ссылки CNA в записи CVE, -- всё равно используют два запроса парольной аутентификации внутри одного соединения: сначала короткий неверный пароль, затем чрезмерно длинный, и оба в своих комментариях к коду объясняют это предварением закешированной соли перед настоящей попыткой. Это не обязательно противоречие. Оба всё равно используют единый транспорт SSH, и "без повторных попыток" наиболее вероятно описывает детерминированность атаки, не требующую повторяющихся вероятностных попыток угадывания, а не утверждает, что одного сообщения аутентификации достаточно. Но это также не решает вопрос в другую сторону. [5] [13] [14]

Прослеживание собственного кода pick_salt() тоже не разрешает это. Функция сканирует и синхронно возвращает пригодную соль в рамках того же вызова при первом выполнении. Ничто в этой логике явно не требует, чтобы более ранний вызов уже произошёл, прежде чем длинный пароль можно будет сопоставить со свежо найденной солью. По собственной логике кода, один, не предварённый чрезмерно длинный пароль, отправленный на самом первом запросе аутентификации соединения, также должен достигать pick_salt(), получать соль обратно и проходить через crypt() против неё -- в точности как делает вторая попытка в обоих известных proof-of-concept.

То, что добавил лабораторный запуск, реально, и это ровно одна экспериментальная точка: однократная попытка провалилась (случай A), предварённая удалась (случай B), в этой одной конкретной сборке. Это подтверждённое хешем свидетельство того, что предварение имело значение в этом одном запуске. Это не объяснение того, почему. Загадка pick_salt() выше остаётся незатронутой этим результатом. "Предварённая работает, однократная -- нет" согласуется с несколькими объяснениями, которые это расследование не подтвердило самостоятельно: деталь собственного срока жизни процессов разделения привилегий OpenSSH (sshd разделяет работу аутентификации каждого соединения между привилегированным процессом-монитором и непривилегированным рабочим процессом; это расследование не проследило, как долго на самом деле живёт каждый из них), конкретно -- какой из этих двух процессов выполняет pick_salt() и охватывает ли его срок жизни одну попытку аутентификации или всё соединение; некое другое поведение с состоянием, которое не идентифицировали ни авторы proof-of-concept, ни это расследование; или что-то специфичное именно для этой сборки и тайминга, что вообще может не обобщаться.

Итак: этот вопрос остаётся открытым на уровне механизма исходного кода после этого лабораторного запуска, точно так же, как и до него. Относитесь к разделению "предварённая успешна, однократная провалилась" как к одной экспериментальной точке, которую стоит воспринимать серьёзно, а не как к решённому объяснению в ту или другую сторону. Следующий полезный шаг, если кто-то захочет его сделать, -- это трассировка точной модели процессов разделения привилегий OpenSSH для этой конкретной сборки, или повторный запуск однократного случая при другом тайминге, чтобы увидеть, сохранится ли результат.

Две меньшие незакрытые нити, отмеченные, а не угаданные: собственный исходный код musl для точной проверки чрезмерно длинного ключа внутри crypt_sha256.c и crypt_sha512.c никогда не был самостоятельно прочитан в этом расследовании -- только описан рекомендацией и отчётом в списке рассылки 2024 года. И является ли имя пользователя GitHub за разбором на sshhole.com тем же человеком, которому примечания к выпуску OpenSSH приписывают заслугу по имени, -- это правдоподобно, судя по совпадающему техническому содержанию, но ничем в записи не подтверждено. Ни то, ни другое не является опорой для какого-либо утверждения, сделанного здесь.

Урок -- о контрактах интерфейсов, а не о musl или OpenSSH

Патчите musl, когда она выпускает исправление. Патчите OpenSSH, у которого исправление уже есть. Ни одна из этих инструкций не является настоящим инженерным уроком здесь.

Контракт интерфейса crypt() обещает NULL при сбое. Собственная реализация этого контракта в musl не всегда сохраняет это обещание, и обещание, на которое отдельно опирается соглашение "*" в /etc/shadow -- что этот конкретный символ никогда не появится как настоящий результат crypt(), -- оказалось полностью зависящим от выполнения первого обещания. Ни одна из подсистем не ошибочна по отдельности: соглашение "*" в /etc/shadow предшествует musl и не имеет с ней никакой связи, а выбор признака сбоя в musl не имеет никакого отношения к OpenSSH или файлам shadow. Ошибка живёт целиком в разрыве между ними, в предположении, которое никто не записал, потому что оно просто всегда выполнялось.

CWE-394, классификация, которую фактически несёт этот CVE, -- это "Unexpected Status Code or Return Value" (неожиданный код состояния или возвращаемое значение), и это именно то, что здесь происходит. Это также "безобидно выглядящая" ошибка в двух разных смыслах одновременно: поле shadow "*" по замыслу выглядит инертным, а поведение "*" при сбое в musl уже было независимо зафиксировано в декабре 2024 года, в совершенно другом контексте, обрушивая pam_unix, почти за два года до того, как кто-либо связал его с обходом в OpenSSH. Одно и то же базовое нарушение контракта, два очень разных последствия, целиком зависящих от того, какой именно вызывающий код к нему обратился.

Практическая версия для любого, кто поддерживает сравнение вроде этого: контракт интерфейса настолько надёжен, насколько каждый вызывающий код готов его проверять, а не настолько, насколько надёжна собственная формулировка контракта. Если вы сравниваете вычисленное значение с хранимым, и у любой из сторон есть документированный признак сбоя "здесь что-то пошло не так", проверьте, может ли этот признак когда-либо оказаться равным значению "здесь нет ничего интересного" другой стороны, прежде чем вообще доверять этому сравнению. Это не проверка, специфичная для musl или для OpenSSH. Это и есть тот настоящий инвариант, которого, как оказалось, не хватало во всём этом CVE.

Это именно та граница, которую создан находить защищённый обзор кода: не очевидная точка инъекции, а сравнение, которое никто не подумал поставить под вопрос, потому что обе стороны всегда выглядели чисто по отдельности. Если в вашем собственном стеке есть путь аутентификации или сравнения на уровне C, который никогда не был прослежен функция за функцией так, как был прослежен этот, -- это именно тот разрыв, который и существует для того, чтобы закрыть его обзор кода на этом уровне.

Источники

Нужна такая проверка для вашего продукта?

Аудит безопасности кода

Хотите сами научиться находить такие уязвимости?

Курс аудита безопасности кода, индивидуально
Все статьи

08Контакт

Выпускаете продукт с данными пользователей?

Пришлите короткое описание. Я отвечу в течение трёх дней вопросами или наброском предложения, а стоимость согласуем письменно до начала работ.