Прошлый год интересно проходил для SSH. Весной — бэкдор в xz-utils (CVE-2024-3094), в результате эксплуатации которого были скомпрометированы системы с systemd, в которых в OpenSSH есть зависимость liblzma, отсутствующая в нем изначально и самим OpenSSH напрямую не используемая (то есть скорее речь об атаке на цепочку поставок этих дистрибутивов, а не конкретно на OpenSSH). В июле — критически опасная уязвимость «состояния гонки» для систем на базе glibc, получившая название regreSSHion (CVE-2024-6387) и представляющая собой перерожденную CVE-2006-5051. Спустя еще неделю была опубликована схожая проблема, получившая идентификатор CVE-2024-6409. А в августе — еще одна, уже специфичная для FreeBSD, CVE-2024-7589.

Как заявляют исследователи, успешная эксплуатация «состояний гонки» позволяет получить RCE на подверженных системах. Более того, regreSSHion — главный баг, ставящий под угрозу безопасность множества SSH-серверов с glibc. Он затрагивает привилегированный процесс sshd: при успешной эксплуатации атакующий сразу получает права суперпользователя, открывающие широчайшие просторы для дальнейших действий злоумышленника (он сможет хоть стереть все подчистую, хоть установить в системе руткит уровня ядра). Исследователи Qualys, изначально обнаружившие regreSSHion, предоставили техническое описание уязвимости с некоторыми подробностями работы своих эксплойтов, реализованных для трех версий sshd для 32-битных систем, из которых наиболее актуальная — 9.2p1 Debian-2+deb12u2. Интересно, что эксплуатация уязвимости не требует особой конфигурации сервера (проблема актуальна и для конфигурации по умолчанию). При этом публичного PoC нет до сих пор. Мы решили разобраться в вопросе: так ли страшны эти «состояния гонки», так ли критически опасны? И какие механизмы в sshd призваны не допустить эксплуатации этой уязвимости или хотя бы уменьшить ущерб в случае успешной атаки?

Статья носит исключительно информационный характер и не является инструкцией или призывом к совершению противоправных действий. Наша цель — рассказать о существующих уязвимостях, которыми могут воспользоваться злоумышленники, предостеречь пользователей и дать рекомендации по защите личной информации в интернете. Авторы не несут ответственности за использование опубликованной информации. Помните, что нужно следить за защищенностью своих данных.

Механизмы безопасности OpenSSH

Существующий более 20 лет, OpenSSH едва ли нуждается в представлении. Этот набор инструментов для администрирования используется в огромном количестве систем, включающих, помимо Unix-подобных систем, еще и Windows. Учитывая возраст и широкую распространенность OpenSSH (согласно Shodan и Censys из 32 млн доступных в сети SSH-серверов около 24 млн работают на OpenSSH), было бы логичным предположить, что в нем используются механизмы безопасности в духе defense in depth. Однако почему-то материалы в сети, посвященные этой теме, крайне неинформативны, не считая отдельных заметок в исходниках OpenSSH. Свежих работ не так много (но одна все же нашлась), поэтому мы попробуем немного восполнить этот пробел.

Фазы аутентификации клиента

Прежде чем говорить о механизмах безопасности OpenSSH более детально, пробежимся по этапам аутентификации клиента:

  • ListenAccepted. Сервер ожидает входящие соединения по умолчанию на TCP-порте 22. При подключении клиента появляются («форкаются») два процесса, один из которых продолжает работать с правами root и сначала получает имя [accepted], а затем [priv]. Второй процесс получает имя [net], он сбрасывает root-права и берет на себя общение с клиентом.

    Дерево sshd сразу после принятия клиентского соединения
    Дерево sshd сразу после принятия клиентского соединения
  • Preauth. Когда клиент инициирует корректный диалог SSHv2, наступает фаза preauth.

  • Post-auth. Наступает после успешной аутентификации. Здесь и обрабатывается интерактивная сессия пользователя.

    В более подробных журналах sshd указывается фаза общения с клиентом
    В более подробных журналах sshd указывается фаза общения с клиентом

Разделение привилегий (privsep)

В исследовании Security measures in OpenSSH (2007), одной из немногих работ, где рассматриваются механизмы безопасности OpenSSH, выделяется privilege separation. Подробнее описывает этот механизм другая работа — Preventing Privilege Escalation из далекого 2003-го.

Разделение привилегий — один из основных механизмов харденинга OpenSSH, и появился он в версии 3.2.3 в 2002 году. Изначально за разделение привилегий отвечала опция конфига UsePrivilegeSeparation, но начиная с версии 7.5 (2017) оно стало обязательным. Небольшие описания реализации privsep «из первых уст» есть в README.privsep и на странице Нильса Провоса, одного из разработчиков OpenSSH.

Цель privsep заключается в уменьшении вероятности получения злоумышленником root-доступа в случае успешной компрометации. Достигается это за счет вынесения функций, не требующих прав root, в отдельный процесс, который сбрасывает uid/gid до специально созданных для этих целей пользователя и группы sshd:sshd с /bin/false в качестве терминала, и за счет выполнения системного вызова chroot, который меняет доступную процессу часть файловой системы на директорию "/var/empty". Такой процесс порождается («форкается») при каждом соединении и отвечает за сетевое взаимодействие с неаутентифицированным клиентом.

Непривилегированный процесс общается с привилегированным процессом монитора посредством пары сокетов. Монитор уже отвечает непосредственно за аутентификацию клиента. Таким образом, с privsep на каждый коннект создается по два процесса вместо одного:

Дерево sshd при попытке аутентификации
Дерево sshd при попытке аутентификации

А с высоты птичьего полета видно следующее: при входящем соединении создаются процессы монитора, после чего происходит обмен ключами и затем аутентификация клиента.  На схеме также видим, что непривилегированный процесс запрашивает у процесса монитора аутентификацию для текущего соединения, и после монитор же принимает решение о создании терминала для аутентифицируемого пользователя.

Обработка клиентского соединения из упомянутых работ по privsep
Обработка клиентского соединения из упомянутых работ по privsep

Если аутентификация не произошла за время LoginGraceTime, по умолчанию равное двум минутам, процесс монитора завершает свои дочерние процессы, посылая им SIGTERM, а затем завершается сам.

С разделением привилегий связан забавный давний «баг» (или фича?), позволявший в свое время при включенном разделении привилегий читать пароли во время их передачи из непривилегированного процесса в процесс монитора, но при этом не работавший с отключенным privsep.

Песочница (seccomp)

Этот механизм призван ограничить доступные системные вызовы для непривилегированного процесса sshd ([net]), который общается с неаутентифицированными клиентами. В коде OpenSSH его называют песочницей.

На момент появления в OpenSSH версии 5.9 preauth песочница для непривилегированного процесса включалась через опцию UsePrivilegeSeparation=sandbox. Как следует из названия, она ограничивает возможности процесса в предаутентификационной фазе, когда клиент еще недоверен. Если клиент и сможет добиться исполнения кода через какую-нибудь уязвимость, при работе этой опции ему будут доступны только системные вызовы, разрешенные в песочнице.

Вариантов песочниц в OpenSSH существует несколько — выбор зависит от ОС (Linux, OpenBSD, macOS…). Нас, само собой, интересует вариант для Linux. Он реализуется на базе seccomp, а применяемые фильтры можно просмотреть в функции ssh_sandbox_child() (preauth_insns). Помимо фильтров, в Linux устанавливается бит процесса PR_SET_NO_NEW_PRIVS, который не позволяет повышать привилегии в результате выполнения execve, например во время запуска файла с установленным suid-битом.

Привилегированный процесс монитора не помещается в песочницу и исполняется от имени пользователя root, поэтому эксплуатируемые уязвимости в нем куда более опасны, чем в непривилегированном процессе sshd.

Механизмы anti-DoS

Помимо рассмотренных «умных» механизмов, обеспечивающих защиту сервера с OpenSSH, нельзя не упомянуть более простые, но все же эффективные способы противостоять брутфорсу.

MaxStartups может использоваться двояко. Если опция задана как одиночное число, то она означает максимальное число возможных одновременных неаутентифицированных соединений. Если же она записана в формате start:rate:full, то сервер начинает «троттлить»: когда число одновременных соединений достигает значения start, sshd начинает отказывать определенному проценту клиентов (rate), линейно повышая вероятность отказа до 100%, достигающихся при максимальном количестве попыток соединения (full). По умолчанию 10:30:100.

Эти механизмы продолжают совершенствоваться, и в версиях 9.8 и 9.9 были добавлены среди прочего:

  • Одно из главных изменений, направленных против брутфорса, — опция PerSourcePenalties: sshd обнаруживает нетипичные попытки аутентификации, например если поведение клиента напоминает попытки подобрать пароль или же если они многократно приводят к падению sshd (что указывает на попытку эксплуатации багов в сервере). В таких случаях sshd регистрирует на какое-то время ограничения (penalty) для адреса, с которого осуществлялось такое подключение. В зависимости от настроенных пороговых значений до истечения времени ограничения начинают блокироваться дальнейшие попытки подключиться с этого адреса либо подсети (PerSourceNetBlockSize). Повторные попытки увеличивают время ограничений до конфигурируемого максимума, при этом можно настроить доверенный пул адресов. Получается своего рода встроенный Fail2Ban.

  • Опция RefuseConnection, закрывающая соединение после первой неудачной попытки аутентификации, и класс ограничений (penalty) refuseconnection для опции sshd-конфига PerSourcePenalties, позволяющий определить ограничения для неудачно аутентифицировавшихся пользователей.

  • Случайная задержка (джиттер) до 4 секунд, добавляемый к значению LoginGraceTime, чтобы не позволить атакующему выиграть «гонку», например при эксплуатации regreSSHion.

Разделяй и властвуй

В мае прошлого года разработчики начали процесс разделения sshd по разным бинарным файлам, что отвечает принципу минимальных привилегий и дополняет общую направленность OpenSSH на харденинг. Это изменение реализовано в OpenSSH версии 9.8, выпущенной вместе с фиксом от regeSSHion. Собственно, sshd теперь отвечает только за валидацию конфига, загрузку ключей на узлах и прослушивание входящих соединений, а клиентские сессии обрабатываются отдельно в sshd-session. Согласно примечаниям к выпуску планируется и дальнейшее разделение файла sshd-session на меньшие части. Например, был выделен (но еще не попал в свежую версию) sshd-auth, перенявший из sshd-session процесс аутентификации пользователя.

Младшие братья regreSSHion: CVE-2024-6409 и CVE-2024-7589

Уязвимости CVE-2024-6409 и CVE-2024-7589, как и сама regreSSHion, представляют собой проблемы «состояния гонки» (race condition). «Состояние гонки» возникает, когда несколько потоков или процессов пытаются получить доступ к одним данным и при этом не синхронизируются. Результат работы в подобной ситуации будет зависеть от того, в каком порядке запланируются операционной системой и исполнятся конкурирующие участки кода. В контексте обсуждаемых уязвимостей, как увидим далее, это касается обращений к структурам аллокатора glibc.

CVE-2024-6409: «состояние гонки» в версиях OpenSSH от дистрибутивов

Уязвимость CVE-2024-6409 по своей сути аналогична regreSSHion, но в этом случае гонка возникает в непривилегированном процессе, то есть эта уязвимость менее опасна при успешной эксплуатации. К тому же она затрагивает не основной код OpenSSH, а некоторые версии с патчами от дистрибутивов: Solar Designer, обнаруживший эту уязвимость в ходе ревью regreSSHion, выделил RHEL 9 и Fedora 36 и 37, но подчеркнул, что возможность эксплуатации не была проверена.

Проблема проявляется, когда изнутри grace_alarm_handler() вызывается cleanup_exit(), она связана с тем, что в последней функции могут вызываться другие функции, небезопасные для вызова в обработчиках сигналов. Такое свойство небезопасности в асинхронных обработчиках сигналов получило название async-signal-unsafe в документации glibc.

CVE-2024-7589: состояние гонки в связке OpenSSH + blacklistd (FreeBSD)

В августе в варианте OpenSSH для FreeBSD исправили вызов небезопасной для обработчиков сигналов функции, специфичной для FreeBSD, — BLACKLIST_NOTIFY(), которая интегрирует sshd с blacklistd. Интересно, что blacklistd является одним из средств защиты от DoS-атак, схожим с fail2ban, но в этом случае он, напротив, ослабляет защищенность.

Взломать интернет: regeSSHion (CVE-2024-6387)

Уязвимость CVE-2024-6387 касается привилегированного процесса sshd, то есть процесса монитора. Это значит, что при успешной эксплуатации злоумышленник не будет ограничен ни seccomp, ни чем-либо еще. А учитывая распространенность использования SSH для администрирования всего подряд…

Корень уязвимости не нов (особенно в контексте того, что это возвращение проблемы почти 20-летней давности): она восходит к вызову нереентерабельной функции внутри обработчика сигнала. Но вот эксплуатация ее нетривиальна. Ниже разберемся, почему же это так.

Функции, небезопасные в обработчиках сигналов

Согласно документации (man signal-safety) есть два пути сделать асинхронный обработчик сигнала безопасным (то есть обладающим свойством async-signal-safe): либо он должен обладать свойством реентерабельности (reentrancy), либо доставка сигнала должна блокироваться до тех пор, пока обработчик находится в одной из небезопасных по отношению к глобальным данным основного кода функций.

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

Но начнем мы издалека. В сервере OpenSSH существует тайм-аут на аутентификацию, равный значению LoginGraceTime (по умолчанию 2 минуты). Он срабатывает за счет выставления обработчика сигнала SIGALRM и установки таймера при подключении клиента. Если в течение этого периода клиент не аутентифицировался и все еще держит открытое соединение, то процессу монитора посылается сигнал, он закрывает соединение и журналирует неуспешную попытку аутентификации.

Сообщения об истечении таймера аутентификации в журнале сервиса sshd
Сообщения об истечении таймера аутентификации в журнале сервиса sshd

Казалось бы, что здесь может пойти не так? Как уже сказано, существуют функции, которые нельзя вызывать в обработчиках сигналов (async-signal-unsafe). К таким функциям относятся, например, printf(), malloc() и free(): первая обновляет внутренние переменные процесса, связанные со стандартным выводом, а две другие изменяют состояние кучи. Одна из работ, посвященных теме безопасности сигналов с точки зрения атакующего, на которую также ссылаются и Qualys (Delivering Signals for Fun and Profit появилась в далеком 2001-м).

Для OpenSSH проблема была в следующем: в устанавливаемом обработчике для SIGALRM, grace_alarm_handler(), вызывалась syslog(), журналирующая неудачную попытку входа. Внутри себя, однако, в некоторых случаях она может аллоцировать буферы и структуры для работы с файлами, а, как мы уже знаем, внутри обработчиков сигналов malloc() небезопасен.

Проблема проявится в случае, если на момент прихода сигнала с внутренними структурами аллокатора производились действия и если при этом обработчик сигнала также оперирует кучей. Такое может произойти, если сигнал пришел во время выполнения другого malloc()/realloc() или free(). Такого положения вещей и добиваются Qualys в своих попытках проэксплуатировать regreSSHion.

В OpenBSD при этом используется более безопасная в отношении сигналов версия syslog()syslog_r(), из-за чего уязвимость к этой системе неприменима. Разработчики реализации musl libc также заявляют, что при использовании их реализации sshd не удастся проэксплуатировать, так как их syslog() не аллоцирует динамически память внутри себя. Более того, из-за того, что в скомпилированном виде musl все еще меньше 2 МБ, он не подвержен проблеме ASLRn’t, которая сильно упрощает эксплуатацию в случае разросшейся glibc из-за сломанной рандомизации на 32-битных системах.

Что делает эксплуатацию regeSSHion возможной

Мы изучим возможности и трудности эксплуатации самой свежей из трех исследованных Qualys версий sshd — 9.2p1 Debian-2+deb12u2. Сначала посмотрим, что нам известно от самих исследователей о том, как они эксплуатируют regreSSHion. Поняв, в чем корень проблемы, они поставили такие задачи:

  1. Найти небезопасный (async-signal-unsafe) код в обработчике сигнала.

  2. Найти код, который может быть прерван обработкой сигнала так, что куча при этом будет неконсистентна, и, как следствие, malloc() сработает на руку атакующему.

  3. Найти способ добраться до этого кода и увеличить шансы оказаться именно там в момент доставки SIGALRM.

Для первого пункта исследователи обнаружили, что обработчик SIGALRM, как уже говорилось, вызывает syslog(), которая может аллоцировать память в куче и потому является async-signal-unsafe. Выделится ли в ней память, зависит от опций запуска sshd: одна из митигаций предлагает использовать опцию "-e", выводящую журнал в stderr вместо syslog, за счет чего уязвимых аллокаций не происходит. В любом случае время и размер этих аллокаций в syslog() атакующий не может контролировать, но вот можно ли повлиять на то, где они выделятся?

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

В конце концов из-за того, что внутри обработчика сигнала вызывается функция, не обладающая свойством async-signal-unsafe, можно испортить внутренние структуры аллокатора ptmalloc (glibc) так, чтобы два выделяемых в недрах syslog() буфера частично перекрывались. Как будет показано далее, это позволяет добиться исполнения (почти) произвольного кода.

Ложный PoC для regreSSHion

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

Сначала на GitHub появился «PoC» от пользователя 7etsuo. В действительности же, как прокомментировали и сами Qualys, этот код ничего не делал: не устанавливал зашифрованный канал, не содержал шеллкода, хотя и включал какие-то элементы из исследования и для неподготовленного читателя выглядел как эксплойт для regreSSHion.

Код из PoC, не содержащий полный подписанный ключ, необходимый для замера времени обработки пакета, чтобы выиграть в «состоянии гонки»
Код из PoC, не содержащий полный подписанный ключ, необходимый для замера времени обработки пакета, чтобы выиграть в «состоянии гонки»

Затем, в соцсети X обнаружили архив, якобы тоже содержащий эксплойт для regreSSHion, но в действительности нацеленный на то, чтобы атаковать исследователей безопасности. Разбор самой вредоносной программы из архива можно почитать в статье.

Логика эксплойта для regreSSHion

Чтобы общаться с sshd посредством пакетов SSH2_* и посылать сформированные ключи-сертификаты, необходимо установить зашифрованный канал. Возможных алгоритмов обмена ключами множество, и клиент с сервером договариваются, что использовать. Они обмениваются версиями и поддерживаемыми алгоритмами обмена ключами, шифрования, аутентификации сообщений (MAC), сжатия. «PoC» от 7etsuo этого не делал.

Сервер предлагает свои поддерживаемые алгоритмы
Сервер предлагает свои поддерживаемые алгоритмы

Когда зашифрованный канал установлен, клиент отправляет сообщение SSH2_MSG_SERVICE_REQUEST с запросом сервиса ssh-userauth, а затем — предлагает серверу свои открытые SSH-ключи в пакетах SSH2_MSG_USERAUTH_REQUEST. Размер одного пакета при этом ограничен значением PACKET_MAX_SIZE, определяемым как 256 КБ.

Ключи-сертификаты

OpenSSH поддерживает несколько способов аутентификации (“Authmethod”), которые могут комбинироваться. Например, это могут быть:

  • none — «никакой», то есть только имя пользователя.

  • password — пароль. Здесь все понятно.

  • publickey — открытый ключ. Проверяется, что соответствующий ключ есть в authorized_keys.

  • Более экзотические варианты: keyboard-interactive, host-based, GSSAPI-based...

Что интересно для нас: открытый ключ может также содержать сертификат, то есть быть подписан чем-то, чему доверяет сервер. Это определяется алгоритмом открытого ключа — pkalg, и, например, для ключа ssh-ed25519 (sshkey_ed25519_impl) подписанный вариант будет называться ssh-ed25519-cert-v01@openssh.com (sshkey_ed25519_cert_impl). Подробнее такие ключи описываются в PROTOCOL.certkeys; подчеркивается, что используемые в SSH сертификаты ключей не являются сертификатами X.509 (в том числе в целях уменьшения поверхности атаки за счет более простого механизма).

Сервер, само собой, вынужден парсить поля этого сертификата. И парсятся они не только в непривилегированном процессе sshd для подтверждения корректности структуры сертификата. Процесс монитора также должен будет распарсить сертификат, чтобы выполнить свои проверки — уже для пользователя, под которым хочет аутентифицироваться клиент. Так входные (вредоносные!) данные атакующего проходят свой путь до незащищенного привилегированного процесса sshd.

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

Это поле может содержать перечисленные через запятую имена, для которых подходит данный подписанный ключ (см. man ssh-keygen, раздел CERTIFICATES). Когда principals парсится, для каждого из перечисленных имен выделяется строка. А после того как завершается обработка пакета с подписанным ключом, все эти строки освобождаются. Таким образом, атакующий контролирует размер и порядок этих аллокаций и знает, как именно они будут освобождаться. Так, можно попробовать создать лейаут кучи, необходимый для эксплуатации бага. И поскольку парсятся principals в двух процессах sshd (сначала в непривилегированном потомке, а затем в процессе монитора), атакующий получает возможность добиться нужного лейаута кучи в куда более привлекательной цели.

Добиваемся нужного состояния кучи

Итак, необходимо получить следующий лейаут кучи на момент доставки сигнала SIGALRM в привилегированном процессе монитора:

Лейаут кучи из отчета Qualys
Лейаут кучи из отчета Qualys

Всего для этого Qualys отправляют пять подписанных SSH-ключей:

  1. Ключ A). Сначала principals этого ключа выделяют чанки для tcache для аллокаций, которые мы не контролируем, — чтобы для них память выделялась из tcache, не вклиниваясь в наши 27 пар чанков. Например: когда очередной principal из ключа парсится, для него выделяется буфер, и адрес этого буфера добавляется в массив указателей распаршенных principals. Если места в массиве не хватает, он переаллоцируется, и нужно сделать так, чтобы он не выделялся посреди нашей последовательности чанков, портя размеры и смещения.

  2. Ключ B). С помощью этого ключа начинают формировать лейаут; principals содержит пары, приблизительно равные 8 КБ + 320 Б, а также guard-чанки (см. раздел «tcache на страже лейаута»), о подборе которых Qualys не рассказывают подробно.

  3. Ключ C). Этим ключом «нарезают» чанки примерно по 8 КБ на половинки и заполняют фиктивные заголовки, чтобы пройти проверки внутри malloc(), когда он будет выделять перекрывающиеся буферы в обработчике сигнала:

    • В середину и конец большого чанка размером 8 КБ записываются фиктивные заголовок и футер чанка remainder (см. раздел unsorted ниже).

    • В маленькие чанки по 320 Б записываются фиктивные значения полей vtable и _codecvt. Они не инициализируются, и если повезет, то эти значения будут использованы в обработчике при записи журналов (переписываемые поля подробно рассмотрены в разделе «От порчи памяти к RCE»).

4.       Ключ D). Этим ключом аллоцируется большая строка. Его цель — вызвать так называемую large allocation, при которой аллокатор перекладывает чанки из unsorted в соответствующие бины (в первую очередь small и large).

5.       Ключ E). В нем посылаются пары, вызывающие malloc(4096) и malloc(304). При обработке этого пакета происходит самое интересное: вызываются 27 окон «состояния гонки» с обработчиком сигнала. Увеличение их числа должно увеличить шансы выиграть гонку. А именно 27 их потому, что максимальный размер SSH-пакета составляет 256 КБ, и это доступный максимум с таким ограничением.

tcache на страже лейаута

Для того чтобы в ходе освобождения строк с principals чанки не оказались объединены с легкой руки malloc_consolidate() и не испортили все наши планы, между ними нужен «барьер» — что-то, что освобождено не будет. Это так называемые guard-чанки. Если бы мы могли контролировать поведение аллокаций и просто не освобождали их, все было бы проще, но увы: все, что мы выделяем, будет освобождено, как только закончится обработка пакета с ключом. Однако выход есть: нужно сделать так, чтобы пары 8 КБ + 320 Б были разделены чанками, которые при освобождении точно попадут в tcache. Но почему именно туда?

Thread Local Cache, или tcache, — это односвязный список освобожденных чанков, добавленный для оптимизации выделений памяти в отдельных потоках, чтобы не приходилось ожидать блокировки кучи на частых аллокациях; tcache (как и fastbin) отличается от более давних бинов small и large тем, что, когда освобождаемые чанки помещаются в tcache, они на самом деле не помечаются как освобожденные — то есть PREV_INUSE у последующего чанка не сбрасывается. Как следствие, в целом для аллокатора они как будто выглядят все равно что занятыми. Это позволяет чанкам в tcache избежать участи быть объединенными со смежными свободными чанками (consolidate backward/forward), в отличие от чанков в бинах small, large и unsorted.

Но раз мы хотим, чтобы guard-чанки оказались в tcache, нужно учитывать наполнение бинов tcache на момент начала парсинга principals из пакета B): в каждом из бинов может лежать не больше 7 чанков, после достижения этого числа чанки начинают складываться в unsorted, а затем сортироваться по бинам small и large.

Воспользуемся pwndbg: эта надстройка над отладчиком GDB позволяет выводить полезную информацию о текущем состоянии структур аллокатора. Команда tcachebins покажет, какие бины tcache заняты и насколько.

Содержимое бинов tcache в процессе монитора на момент начала парсинга ключа B)
Содержимое бинов tcache в процессе монитора на момент начала парсинга ключа B)

Нам повезло: полностью свободных бинов предостаточно для того, чтобы разделить все 27 пар и выстроить желаемый лейаут. Как видно на скрине выше, на момент начала парсинга principals из ключа B) в tcache совершенно не заняты бины из диапазона размеров 0×260–0×2a0, которые можно взять для guard-чанков.

В недрах syslog() и откуда берутся 320 байт

Пришло время обсудить, почему именно такой лейаут нужно получить. Почему большой чанк — на 8 КБ? Откуда 320 Б? Почему именно в таком порядке?

Из нескольких аллокаций, происходящих в syslog() (а если точнее — немного глубже, в __tzfile_read()), потенциально интересны две: выделение структуры FILE при вызове fopen() и выделение файлового буфера в __fread_unlocked().

Структура FILE — одна из горячо любимых в задачах эксплуатации, настолько, что наряду с ROP и SROP выделяется FSOP, File Stream Oriented Programming. В ней много интересных полей и указателей, потенциально позволяющих писать и читать произвольную память, а в особо удачных случаях и вовсе добиться RCE, перезаписав что-нибудь в таблице переходов.

Вызов malloc(0x130) для структуры locked_FILE (надстройка над FILE) внутри fopen() в обработчике сигнала
Вызов malloc(0x130) для структуры locked_FILE (надстройка над FILE) внутри fopen() в обработчике сигнала

При вызове malloc() аллокатор на самом деле выделяет немного больше памяти, чем было запрошено:

  • Минимум 8 или 16 Б нужно на заголовок чанка в зависимости от размера указателя целевой архитектуры.

  • Еще 16 Б может потребоваться, если поддерживается и включено тегирование памяти.

  • Также размер аллокации дополняется до кратности минимум 2 * size_t на целевой архитектуре. В случае i386 — до кратности 16 Б.

Так, мы подходим к пониманию, почему нужны были маленькие чанки на 320 Б: 304 (0×130) Б из описания пакета E) занимает структура, аллоцируемая внутри fopen(), еще 8 — заголовки чанка. С выравниванием это дает полный размер чанка в 320 (0×140) Б.

Структура, которая выделится на самом деле при вызове fopen()
Структура, которая выделится на самом деле при вызове fopen()

После fopen() в обработчике сигнала внутри __fread_unlocked() выделится файловый буфер на 4 КБ. В случае, если атакущий выиграет «состояние гонки», в результате этой аллокации окажется переписанным одно из полей структуры FILE (см. раздел «От порчи памяти к RCE»).

unsorted

unsorted — особый бин, работа которого играет ключевую роль в перезаписи полей структуры FILE. В него попадают чанки:

  • Сразу при освобождении (если tcache соответствующего размера уже заполнен).

  • При аллоцировании, если был разбит чанк, размер которого больше требуемого в запросе. В этом случае первая часть возвращается пользователю, а остаток от разделения (remainder) помещается в бин unsorted.

Как следует из названия, размеры чанков в бине unsorted не отсортированы и могут оказаться самыми разными; malloc() будет искать подходящий чанк в следующем порядке (некоторые несущественные для нас детали здесь опущены):

  1. Ищется точное совпадение по размеру в бинах tcache.

  2. Проверяются бины fast. Если, помимо подходящего, в бине имеются еще чанки и есть место в соответствующем tcache, то оставшиеся свободные чанки перемещаются в tcache.

  3. Таким же образом проверяются бины small.

  4. Проверяется бин unsorted. В случае точного совпадения по размеру возвращается чанк из него. Все остальные чанки из unsorted сортируются по бинам small и large в соответствии с размером.

  5. Проверяются бины large, пока не будет найден наименьший подходящий чанк. Если он больше, чем запрошено, остаток попадет снова в unsorted.

  6. Если ничего не нашлось, берется часть «топ-чанка» (он же иногда называется чанком wilderness).

В качестве результата «состояния гонки» Qualys добиваются ситуации, при которой SIGALRM пришел, когда remainder от чанка на 8 КБ (то есть при аллокации файлового буфера на 4 КБ) уже добавлен в unsorted (то есть после 4324-4327), но поля с его размерами еще не проинициализированы (то есть перед 4339-4340):

Выделение чанка из бина large и помещение остатка от него обратно в unsorted
Выделение чанка из бина large и помещение остатка от него обратно в unsorted

В таком случае мы получим в бине unsorted чанк, размер которого можно контролировать. Это позволит заставить следующий malloc() считать, что чанк больше, чем есть на самом деле, и выдать перекрывающиеся аллокации. А затем мы сможем перезаписать в одной из них что-то, что позволит достичь RCE.

Но сначала нужно учесть проверки, выполняемые malloc() при прохождении по бину unsorted. Важно не нарушить связность списка и консистентность данных в смежных чанках (их размеры и сброшенный бит PREV_INUSE в заголовке чанка, следующего в памяти за потенциально выделяемым):

Проверки malloc() для чанков в бине unsorted
Проверки malloc() для чанков в бине unsorted

Интересно, что, хотя на целевой системе чанки, явно выделяемые аллокатором, выравниваются по 16 Б, в коде нет чего-либо, что помешало бы выделить из бина unsorted чанк, выровненный более гранулярно, по 8 Б. Это пригодится, чтобы перезапись попала в нужное поле FILE, не затронув ненужных полей. Стоит лишь учитывать, что младшие 3 бита поля размера используются под флаги. Нужно не забыть о липовом футере — заголовках якобы следующего чанка, которые будут проверяться, а именно о «размере» его самого (size на скрине выше), prev_size и сброшенном флаге PREV_INUSE.

Формирование фейковых заголовков будущего чанка remainder через отправку пакета C)
Формирование фейковых заголовков будущего чанка remainder через отправку пакета C)

Отдельно повезло, что адреса в glibc, которые нам нужно будет указать в полях FILE, не содержат нуль-байтов. Principals в сертификате сериализуются согласно RFC 4251 (раздел 5) и представляют собой строку с указанной перед ней в uint32 длиной.

Фрагмент закодированных principals из пакета B)
Фрагмент закодированных principals из пакета B)

Principals парсятся функцией sshbuf_get_cstring(), которая не разрешает нуль-байты нигде, кроме конца строки. В поле размера фейкового чанка remainder (0x1109 = 0x1108 | PREV_INUSE) нужно как-то записать 2 нулевых байта подряд, иначе в старшем байте этого поля останется значение из предыдущего пакета и размер не пройдет проверку size > av->system_mem, даже если там будет 0×01 (0 там быть не может, иначе бы вместо principal на 8 КБ распарсился бы principal на 4 КБ).

Впрочем, в sshbuf_get_cstring() нашлась приятная особенность, благодаря которой наша проблема решается сама собой. Эта функция не только сама всегда завершает строки нулем — если в конце входной строки есть нуль-байт, то он тоже сохранится в выходе. При этом RFC 4251 подчеркивает, что нуль-байтов конце закодированных строк по-хорошему быть не должно.

При освобождении пакета C) principals объединятся обратно в 8-килобайтные чанки, которые затем снова разделятся в пакете E). Все готово для чанка remainder, который в обработчике сигнала поможет переписать какое-нибудь интересное поле в структуре FILE.

Добавленный в бин unsorted чанк remainder в момент, когда поле размера еще не проинициализировано (вместо 0×1109 должно быть записано 0×cf1)
Добавленный в бин unsorted чанк remainder в момент, когда поле размера еще не проинициализировано (вместо 0×1109 должно быть записано 0×cf1)
Проверки при выделении увеличенного чанка remainder из unsorted
Проверки при выделении увеличенного чанка remainder из unsorted

От порчи памяти к RCE

Что дают перекрывающиеся буферы в случае победы в «состоянии гонки» и как с их помощью достичь исполнения кода?

На текущий момент мы добились того, что чанк размера 0×1010, который выделится при запросе malloc(0×1000) внутри syslog(), будет выделен из увеличенного чанка remainder, размер которого мы контролируем, как описано в предыдущем разделе. У этого чанка remainder интересный путь:

  • Сразу после создания этот чанк remainder помещается в unsorted.

  • Когда в недрах syslog() происходит выделение буфера на 4 КБ (0×1000), аллокатор проходится сначала по бину unsorted в поисках точного совпадения, перекладывая неподходящие чанки в соответствующие бины small и large. Так, наш чанк remainder попадает в свой бин large, поскольку он немного больше запрашиваемого.

  • Аллокатор перебирает бины large, где наиболее подходящим для удовлетворения запроса находит наш чанк remainder. Но раз чанк этот немного больше, чем нужно, он будет снова разделен: первая часть вернется в syslog(), а вторая отправится, в свою очередь, в unsorted.

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

Второй remainder пересекается с выделенной ранее структурой FILE
Второй remainder пересекается с выделенной ранее структурой FILE

Мы не вполне контролируем, что конкретно запишется при добавлении чанка remainder в бин unsorted, — то есть какие значения окажутся в заголовках чанка в указателях fd, bk и поле size. Но мы можем влиять на то, куда эти значения попадут. Тогда остается вопрос: а что и куда можно записать так, чтобы это повлияло на исполнение внутри обработчика сигнала до того, как он завершит процесс в sshsigdie()?

Если правильно рассчитать смещения, то можно сделать так, что в поле vtable_offset окажется значение 0×61 — третий байт указателя на бин unsorted в glibc. Тогда будет использоваться таблица переходов, адрес которой находится в структуре FILE по смещению FILE.vtable + FILE.vtable_offset = 0×94 + 0×61 = 0×f5. Но записать туда что угодно не получится: начиная с glibc 2.24 таблица переходов должна находиться в специальной секции __libc_IO_vtables, в противном случае придется обходить более замысловатые проверки в функции _IO_vtable_check().

Поля FILE перезаписаны значениями fd и bk (подчеркнуто желтым) после добавления второго чанка remainder в бин unsorted. Оранжевым выделено поле _vtable_offset, голубым — поле _codecvt, малиновым — адрес таблицы vtable, на которую произойдет переход
Поля FILE перезаписаны значениями fd и bk (подчеркнуто желтым) после добавления второго чанка remainder в бин unsorted. Оранжевым выделено поле _vtable_offset, голубым — поле _codecvt, малиновым — адрес таблицы vtable, на которую произойдет переход

Таким образом, надо исходить из того, что в качестве таблицы переходов доступны только адреса, находящиеся в __libc_IO_vtables. На эту роль была выбрана определенная в самой glibc таблица _IO_wfile_jumps, предназначенная для работы с wide char. Тогда дальше в __fread_unlocked() вместо обычной _IO_file_underflow() будет вызвана _IO_wfile_underflow().

Таблица переходов _IO_wfile_jumps
Таблица переходов _IO_wfile_jumps
Проверка vtable, указанной нами в FILE. Она действительно находится в требуемом диапазоне
Проверка vtable, указанной нами в FILE. Она действительно находится в требуемом диапазоне
Проверка успешна — произойдет переход на IO_wfile_underflow()
Проверка успешна — произойдет переход на IO_wfile_underflow()

_IO_wfile_underflow() отличается тем, что производит обработку локалей, для чего обращается к полю _codecvt в FILE. Там ожидается указатель на структуру _IO_codecvt, в которой, в свою очередь, два поля типа _IO_iconv_t:

Структура _IO_codecvt и ее поля _IO_iconv_t
Структура _IO_codecvt и ее поля _IO_iconv_t

В этих полях нас интересует поле step — указатель на структуру __gconv_step, которая определяет шаг процесса конвертации символа. В ней лежит __fct, указывающий на функцию, которая в конечном итоге будет вызвана в __libio_codecvt_in().

Структура __gconv_step. По смещению 0×14 видим интересующее поле __fct
Структура __gconv_step. По смещению 0×14 видим интересующее поле __fct

И ради всего этого лабиринта структур и указателей и была выбрана эта таблица переходов! Теперь только осталось сделать так, чтобы этот лабиринт привел в контролируемую нами память.

Указатель на _codecvt находится в структуре FILE и при ее аллоцировании в fopen() явно не инициализируется. Если эта структура FILE будет выделена из одного из наших 320-байтных чанков, то там окажется — и будет использовано в _IO_wfile_underflow() — наше значение _codecvt. Но вот куда оно должно указывать? Мы «знаем» только адреса из glibc (благодаря ASLRn’t) — но никакие адреса на куче, где находятся наши чанки, нам не известны. Нужно же сделать так, чтобы по адресу в _codecvt происходил переход на наши данные.

К счастью, особенности устройства аллокатора glibc, а именно списка бинов small, идут нам на выручку. Мы действительно не знаем, где находятся наши чанки, но зато знаем, что ссылки на них находятся в бине small на 0×140 Б, то есть он хранит их адреса. А таблицы бинов хранятся в области памяти glibc, то есть известны нам.

Контролируемый чанк, на который указывает bk из списка smallbin для чанков по 0×140 байт. По выделенному смещению находится __gconv_step->__fct
Контролируемый чанк, на который указывает bk из списка smallbin для чанков по 0×140 байт. По выделенному смещению находится __gconv_step->__fct
Переход на контролируемый адрес внутри __libio_codecvt_in()
Переход на контролируемый адрес внутри __libio_codecvt_in()
Segfault в результате перехода управления на контролируемый адрес в step->__fct
Segfault в результате перехода управления на контролируемый адрес в step->__fct

В конце концов будет пройден тернистый путь получения RCE.

От порчи структуры FILE до перехода на контролируемый атакующим адрес
От порчи структуры FILE до перехода на контролируемый атакующим адрес

Вместо выводов и размышления об x64

Как видим, эксплуатация regreSSHion далеко не тривиальна. В этой статье мы как минимум не охватили то, как вообще эту гонку выиграть и что делать дальше после того, как получилось перехватить управление. Ведь в первую очередь мы исходим из того, что «знаем» только адреса glibc, а сделать что-то более интереснее, чем просто перейти на некоторый адрес из glibc, — отдельная задача.

Кроме того, Qualys отмечают, что, во-первых, техника с использованием vtable_offset специфична для glibc на i386, а версия на x64 не обращается к этому полю. Во-вторых, ASLR на x64 не подвержен той же проблеме, которая позволяет в доброй половине случаев угадывать базу libc на 32-битной системе с glibc.

Несмотря на все это, не следует расслабляться. Да, в эксплуатации regreSSHion действительно много «но», и дополнительно усложняют задачу атакующему механизмы защиты в самом OpenSSH, которые разработчики еще и постоянно совершенствуют. И тем не менее Qualys говорят о возможности создания эксплойта для 64-битных систем, да и всегда могут присутствовать баги, о которых мы попросту еще не знаем. Поэтому следует не забывать о необходимости своевременного обновления, а также о многоуровневой защите, которая даже при наличии уязвимости может сделать ее эксплуатацию крайне трудоемкой или — в лучшем случае — и вовсе невозможной.

Комментарии (1)


  1. maxwolf
    29.01.2025 19:03

    Спасибо! Читается как захватывающий детектив!