
This is a POC I wrote for CVE-2024-6387
Qualys Security Advisory
regreSSHion: RCE в сервере OpenSSH на системах Linux на базе glibc (CVE-2024-6387)
Краткое описание SSH-2.0-OpenSSH_3.4p1 Debian 1:3.4p1-1.woody.3 (Debian 3.0r6, от 2005)
Всё, что нужно — это прыжок веры
-- The Interrupters, "Leap of Faith"
Предварительное замечание: OpenSSH — одно из самых защищённых программных обеспечений в мире; эта уязвимость — одна оплошность в иначе почти безупречной реализации. Его архитектура защиты в глубину и код являются образцом и источником вдохновения, и мы благодарим разработчиков OpenSSH за их образцовую работу.
Мы обнаружили уязвимость (состояние гонки в обработчике сигналов) в сервере OpenSSH (sshd): если клиент не проходит аутентификацию в течение LoginGraceTime секунд (по умолчанию 120, 600 в старых версиях OpenSSH), то обработчик SIGALRM sshd вызывается асинхронно, но этот обработчик сигнала вызывает различные функции, которые не являются async-signal-safe (например, syslog()). Это состояние гонки затрагивает sshd в его конфигурации по умолчанию.
В ходе расследования мы поняли, что эта уязвимость на самом деле является регрессией CVE-2006-5051 ("Состояние гонки в обработчике сигналов в OpenSSH до версии 4.4, позволяющее удалённым злоумышленникам вызвать отказ в обслуживании (аварийное завершение) и, возможно, выполнить произвольный код"), которая была сообщена в 2006 году Марком Даудом.
Эта регрессия была введена в октябре 2020 года (OpenSSH 8.5p1) коммитом 752250c ("revised log infrastructure for OpenSSH"), который случайно удалил "#ifdef DO_LOG_SAFE_IN_SIGHAND" из sigdie(), функции, которая напрямую вызывается обработчиком SIGALRM в sshd. Другими словами:
OpenSSH < 4.4p1 уязвим к этому состоянию гонки в обработчике сигналов, если не был пропатчен с обратной совместимостью против CVE-2006-5051 или не исправлен против CVE-2008-4109, который был некорректным исправлением для CVE-2006-5051;
4.4p1 <= OpenSSH < 8.5p1 не уязвим к этому состоянию гонки в обработчике сигналов (потому что "#ifdef DO_LOG_SAFE_IN_SIGHAND", добавленный в sigdie() патчем для CVE-2006-5051, превратил эту небезопасную функцию в безопасный вызов _exit(1));
8.5p1 <= OpenSSH < 9.8p1 снова уязвим к этому состоянию гонки в обработчике сигналов (потому что "#ifdef DO_LOG_SAFE_IN_SIGHAND" был случайно удалён из sigdie()).
Эта уязвимость эксплуатируется удалённо на системах Linux на базе glibc, где syslog() сам вызывает async-signal-unsafe функции (например, malloc() и free()): неаутентифицированное удалённое выполнение кода с правами root, поскольку затрагивает привилегированный код sshd, который не изолирован (sandboxed) и работает с полными правами. Мы не исследовали другие libc или операционные системы; но OpenBSD, заметим, не уязвим, потому что его обработчик SIGALRM вызывает syslog_r(), более безопасную для async-signal версию syslog(), которая была изобретена OpenBSD в 2001 году.
Чтобы удалённо эксплуатировать эту уязвимость (насколько нам известно, CVE-2006-5051 ранее никогда не был успешно эксплуатирован), мы черпали вдохновение из провидческой статьи "Delivering Signals for Fun and Profit", опубликованной в 2001 году Михалом Залевским:
https://lcamtuf.coredump.cx/signals.txt
Тем не менее, мы сразу столкнулись с тремя основными проблемами:
С теоретической точки зрения, мы должны найти полезный путь кода, который, если его прервать в нужный момент сигналом SIGALRM, оставляет sshd в нестабильном состоянии, и затем мы должны эксплуатировать это нестабильное состояние внутри обработчика SIGALRM.
С практической точки зрения, мы должны найти способ достичь этого полезного пути кода в sshd и максимизировать наши шансы прервать его в нужный момент.
С точки зрения времени, мы должны найти способ ещё больше увеличить наши шансы прервать этот полезный путь кода в нужный момент удалённо.
Чтобы сосредоточиться на этих трёх проблемах, не борясь сразу со всеми современными защитами операционной системы (в частности, ASLR и NX), мы решили сначала эксплуатировать старые версии OpenSSH на i386, а затем, основываясь на этом опыте, современные версии:
Сначала, "SSH-2.0-OpenSSH_3.4p1 Debian 1:3.4p1-1.woody.3", из "debian-30r6-dvd-i386-binary-1_NONUS.iso": это первая версия Debian, в которой разделение привилегий включено по умолчанию и которая пропатчена против всех критических уязвимостей той эпохи (в частности, CVE-2003-0693 и CVE-2002-0640).
Чтобы удалённо эксплуатировать эту версию, мы прерываем вызов free() сигналом SIGALRM (внутри кода разбора открытого ключа sshd), оставляем кучу в нестабильном состоянии и эксплуатируем это нестабильное состояние во время другого вызова free() внутри обработчика SIGALRM.
В наших экспериментах в среднем требуется ~10 000 попыток, чтобы выиграть это состояние гонки; т.е. при 10 соединениях (MaxStartups), принятых за 600 секунд (LoginGraceTime), в среднем требуется ~1 неделя, чтобы получить удалённую root-оболочку.
Во-вторых, "SSH-2.0-OpenSSH_4.2p1 Debian-7ubuntu3", из "ubuntu-6.06.1-server-i386.iso": это последняя версия Ubuntu, которая ещё уязвима к CVE-2006-5051 ("Состояние гонки в обработчике сигналов в OpenSSH до версии 4.4").
Чтобы удалённо эксплуатировать эту версию, мы прерываем вызов pam_start() сигналом SIGALRM, оставляем одну из структур PAM в нестабильном состоянии и эксплуатируем это нестабильное состояние во время вызова pam_end() внутри обработчика SIGALRM.
В наших экспериментах в среднем требуется ~10 000 попыток, чтобы выиграть это состояние гонки; т.е. при 10 соединениях (MaxStartups), принятых за 120 секунд (LoginGraceTime), в среднем требуется ~1-2 дня, чтобы получить удалённую root-оболочку.
Наконец, "SSH-2.0-OpenSSH_9.2p1 Debian-2+deb12u2", из "debian-12.5.0-i386-DVD-1.iso": это текущая стабильная версия Debian, и она уязвима к регрессии CVE-2006-5051.
Чтобы удалённо эксплуатировать эту версию, мы прерываем вызов malloc() сигналом SIGALRM (внутри кода разбора открытого ключа sshd), оставляем кучу в нестабильном состоянии и эксплуатируем это нестабильное состояние во время другого вызова malloc() внутри обработчика SIGALRM (точнее, внутри syslog()).
В наших экспериментах в среднем требуется ~10 000 попыток, чтобы выиграть это состояние гонки, так что ~3-4 часа при 100 соединениях (MaxStartups), принятых за 120 секунд (LoginGraceTime). В конечном счёте, в среднем требуется ~6-8 часов, чтобы получить удалённую root-оболочку, потому что мы можем правильно угадать адрес glibc только в половине случаев (из-за ASLR).
Это исследование ещё в процессе:
мы нацеливались только на виртуальные машины, не на bare-metal серверы, на в основном стабильной сетевой линии (~10ms джиттер пакетов);
мы убеждены, что различные аспекты наших эксплойтов могут быть значительно улучшены;
мы начали работу над эксплойтом для amd64, что гораздо сложнее из-за более сильного ASLR.
Через несколько дней после начала нашей работы над amd64 мы заметили следующий отчёт об ошибке (в публичном Bugzilla OpenSSH) о взаимоблокировке в обработчике SIGALRM sshd:
https://bugzilla.mindrot.org/show_bug.cgi?id=3690
Поэтому мы решили немедленно связаться с разработчиками OpenSSH (чтобы сообщить им, что эта взаимоблокировка вызвана эксплуатируемой уязвимостью), приостановили нашу работу над amd64 и начали писать это уведомление.
Но это не похоже на меня, я освобождаюсь
-- The Interrupters, "Haven't Seen the Last of Me"
Обработчик SIGALRM этой версии OpenSSH вызывает packet_close(), который вызывает buffer_free(), который вызывает xfree() и, следовательно, free(), что не является async-signal-safe:
Следовательно, мы начали читать код malloc этой glibc Debian (2.2.5), чтобы увидеть, можно ли прервать первый вызов free() сигналом SIGALRM и эксплуатировать его во время второго вызова free() внутри обработчика SIGALRM (в строках 341-344 выше). Поскольку malloc этой glibc не защищён от техники unlink(), впервые предложенной Solar Designer в 2000 году, мы быстро заметили интересный путь кода в chunk_free() (которая вызывается внутри free()):
Чтобы эксплуатировать этот путь кода, мы организуем кучу sshd со следующим расположением (chunk_X, chunk_Y и chunk_Z — это выделенные malloc() блоки памяти, а p, s, f, b — их поля prev_size, size, fd и bk):
-----|---+---------------|---+---------------|---+---------------|----- ... |p|s|f|b| chunk_X |p|s|f|b| chunk_Y |p|s|f|b| chunk_Z | ... -----|---+---------------|---+---------------|---+---------------|----- |<------------->| user data
Во-первых, если вызов free(chunk_Y) прерывается сигналом SIGALRM после строки 3246, но до строки 3251, то chunk_Y уже помечен как свободный (потому что бит PREV_INUSE chunk_Z очищен в строке 3246), но ещё не связан в свой двусвязный список (в строке 3251): другими словами, указатели fd и bk chunk_Y всё ещё содержат пользовательские данные (данные, контролируемые злоумышленником).
Во-вторых, если (внутри обработчика SIGALRM) packet_close() вызывает free(chunk_X), то входим в блок кода в строках 3230-3244 (поскольку chunk_Y помечен как свободный) и chunk_Y раз связывается (unlink()) (в строке 3241): так называемая примитивная операция aa4bmo (почти произвольная 4-байтовая зеркальная перезапись), потому что указатели fd и bk chunk_Y всё ещё контролируются злоумышленником. Для получения дополнительной информации о технике unlink() и примитиве aa4bmo:
https://www.openwall.com/articles/JPEG-COM-Marker-Vulnerability#exploit http://phrack.org/issues/61/6.html#article
Наконец, с помощью этого примитива aa4bmo мы перезаписываем указатель функции __free_hook в glibc (в этой старой версии Debian нет ASLR, а также NX) адресом нашего шелл-кода в куче, тем самым достигая удалённого выполнения кода во время следующего вызова free() в packet_close().
Теперь они берут верх и полностью контролируют
-- The Interrupters, "Liberty"
Чтобы провести эту атаку на sshd, мы прерываем вызов free() внутри кода разбора открытого ключа DSA в sshd (т.е. строка 144 ниже — это наш free(chunk_Y)) и эксплуатируем его во время одного из вызовов free() в packet_close() (т.е. одна из строк 341-344 выше — это наш free(chunk_X)):
Однако поначалу нам никогда не удавалось выиграть это состояние гонки (т.е. прервать вызов free() в строке 144 в нужный момент). В конце концов мы поняли, что можем значительно улучшить наши шансы на победу в этой гонке: код разбора открытого ключа DSA позволяет нам вызвать free() четыре раза (в строках 704-707 ниже), и, более того, sshd позволяет нам попробовать шесть аутентификаций пользователя (AUTH_FAIL_MAX); если любой из этих 24 вызовов free() будет прерван в нужный момент, то затем мы добьёмся удалённого выполнения кода внутри обработчика SIGALRM.
С этим улучшением мы наконец выиграли состояние гонки примерно через ~1 месяц: мы были счастливы (и станцевали танец root-оболочки), но также почувствовали, что ещё есть возможности для улучшения.
Не волнуйся, просто подожди и увидишь
-- The Interrupters, "Haven't Seen the Last of Me"
Поэтому мы реализовали следующую трёхчастную стратегию времени:
Мы не ждём последнего момента, чтобы отправить наш (довольно большой) пакет с открытым ключом DSA в sshd: вместо этого мы отправляем весь пакет минус один байт (последний байт) задолго до LoginGraceTime и отправляем самый последний байт в самый последний момент, чтобы минимизировать влияние сетевых задержек. (И мы отключаем алгоритм Нейгла.)
Мы отслеживаем медианное время кругового пути (регулярно отправляя пакеты, которые вызывают ответ от sshd), и отслеживаем разницу между моментом, когда мы ожидаем, что наше соединение будет закрыто sshd (по сути, момент получения первого байта баннера sshd плюс LoginGraceTime), и моментом, когда наше соединение действительно закрыто sshd, и соответственно корректируем наше время (т.е. момент отправки последнего байта нашего пакета DSA).
Эти разницы во времени позволяют нам отслеживать расхождения часов и сетевые задержки, которые со временем демонстрируют предсказуемые закономерности: мы экспериментировали с линейной и сплайн-регрессией, но в итоге ничто не сработало лучше, чем простое повторное использование самого последнего измерения. Возможно, глубокое обучение может дать ещё лучшие результаты; это оставлено в качестве упражнения для заинтересованного читателя.
Что более важно, мы дополнительно увеличиваем наши шансы на победу в этом состоянии гонки, медленно корректируя наше время через непроизвольную обратную связь от sshd:
если мы получаем ответ (SSH2_MSG_USERAUTH_FAILURE) на наш пакет с открытым ключом DSA, значит, мы отправили его слишком рано (sshd успел получить наш пакет в непривилегированном дочернем процессе, разобрать его, отправить привилегированному дочернему процессу, разобрать там и отправить ответ обратно нам);
если мы не можем даже отправить последний байт нашего пакета DSA, значит, мы ждали слишком долго (sshd уже получил SIGALRM и закрыл наше соединение);
если мы можем отправить последний байт нашего пакета DSA и не получаем ответа до того, как sshd закроет наше соединение, значит, наше время было достаточно точным.
Эта обратная связь позволяет нам нацелиться на то, что мы называем "большим" окном гонки: попадание в него не гарантирует, что мы выиграем состояние гонки, но внутри этого большого окна находятся 24 "маленьких" окна гонки (внутри 24 вызовов free()), которые, если в них попасть, гарантируют, что мы выиграем состояние гонки.
С этими улучшениями в среднем требуется ~10 000 попыток, чтобы выиграть это состояние гонки; т.е. при 10 соединениях (MaxStartups), принятых за 600 секунд (LoginGraceTime), в среднем требуется ~1 неделя, чтобы получить удалённую root-оболочку.
Я сплю, когда начинает восходить солнце
-- The Interrupters, "Alien"
Обработчик SIGALRM этой версии OpenSSH больше не вызывает packet_close(); более того, glibc этой Ubuntu (2.3.6) всегда захватывает обязательную блокировку при входе в функции семейства malloc (даже если однопоточная, как sshd), что мешает нам прервать вызов одной из функций malloc и позже эксплуатировать его во время другого вызова этих функций (они всегда будут взаимоблокироваться). Мы должны найти другое решение.
CVE-2006-5051 упоминает double-free в GSSAPI, но GSSAPI (или Kerberos) не включены по умолчанию, так что это не выглядит очень привлекательным. С другой стороны, PAM включен по умолчанию, и pam_end() вызывается обработчиком SIGALRM в sshd (и, конечно, не является async-signal-safe). Поэтому мы искали функцию PAM, которая, если её прервать сигналом SIGALRM в нужный момент, оставит внутренние структуры PAM в нестабильном состоянии, пригодном для эксплуатации во время pam_end() в обработчике SIGALRM. Мы нашли pam_set_data():
33 int pam_set_data(
34 pam_handle_t *pamh,
..
37 void (*cleanup)(pam_handle_t *pamh, void *data, int error_status))
38 {
39 struct pam_data *data_entry;
..
57 } else if ((data_entry = malloc(sizeof(*data_entry)))) {
..
65 data_entry->next = pamh->data;
66 pamh->data = data_entry;
..
74 data_entry->cleanup = cleanup;
------------------------------------------------------------------------Если эта функция будет прервана сигналом SIGALRM после строки 66, но до
строки 74, то data_entry уже будет связан со структурами PAM (pamh),
но его поле cleanup (указатель на функцию) ещё не будет инициализировано (так
как malloc() в строке 57 не инициализирует свою память). Если мы сможем
контролировать cleanup (через остатки от предыдущих выделений в куче), то
сможем выполнить произвольный код, когда pam_end() (внутри обработчика
SIGALRM) вызовет _pam_free_data() (в строке 118):
Это был бы чрезвычайно простой эксплойт; к сожалению, мы совершенно
упустили, что pam_set_data() может быть вызвана только из модулей PAM:
если мы прерываем её сигналом SIGALRM, то pamh->caller_is по-прежнему
остаётся _PAM_CALLED_FROM_MODULE, и в этом случае pam_end() возвращается
немедленно, даже не вызывая _pam_free_data(). Возвращаемся к черновику.
Не сдаёмся — это не в наших правилах
-- The Interrupters, "Title Holder"
Мы заметили, что в строке 601 ниже sshd передаёт указатель на свой
глобальный указатель sshpam_handle напрямую в pam_start() (которая
вызывается один раз на каждое соединение):
Поэтому мы решили исследовать саму pam_start(): если её прервать сигналом
SIGALRM, она может оставить структуру, на которую указывает sshpam_handle,
в несогласованном состоянии, которое затем можно использовать внутри
обработчика SIGALRM, когда вызывается pam_end(sshpam_handle, sshpam_err).
В строке 32 pam_start() немедленно устанавливает sshpam_handle в
sshd на блок памяти, выделенный через calloc(); это безопасно, потому
что calloc() обнуляет эту память. С другой стороны, если _pam_add_handler()
(которая вызывается несколько раз из pam_start()) будет прервана сигналом
SIGALRM после строки 874, но до строки 886, то структура, выделенная
через malloc(), будет связана с pamh, но её поле next ещё не будет
инициализировано. Если мы сможем управлять next (через остатки от
предыдущих выделений в куче), то сможем передать произвольный указатель в
free() во время вызова pam_end() (внутри обработчика SIGALRM) в строках
1020 (и 1017) ниже:
Поскольку malloc в glibc этой Ubuntu уже защищён от старой техники
unlink(), мы решили преобразовать наш произвольный free() в "House of
Mind" из Malloc Maleficarum (версия для быстрого бина): мы освобождаем
свой собственный блок NON_MAIN_ARENA, указываем нашу поддельную арену на
.got.plt от sshd (эта Ubuntu имеет ASLR, но не PIE для sshd) и
перезаписываем запись _exit() адресом нашего шеллкода в куче (куча в этой
Ubuntu по-прежнему исполняема по умолчанию). Подробнее о Malloc Maleficarum:
https://seclists.org/bugtraq/2005/Oct/118
Я всё познавал трудным путём
-- The Interrupters, "The Hard Way"
Чтобы провести эту атаку против sshd, изначально мы столкнулись с тремя
проблемами:
«House of Mind» требует, чтобы мы сохранили указатель на нашу поддельную
арену по адресу 0x08100000 в куче; но сможем ли мы сохранить подконтрольные
злоумышленнику данные по такому высокому адресу? Поскольку sshd вызывает
pam_start() в самом начале аутентификации пользователя, мы не контролируем
ничего, кроме самого имени пользователя; к счастью, имя пользователя длиной
~128 КБ (меньше DEFAULT_MMAP_THRESHOLD) позволяет нам сохранить свои
данные по адресу 0x08100000.
Поле размера нашего поддельного блока NON_MAIN_ARENA не должно быть
слишком большим (чтобы пройти проверки безопасности free()); то есть оно
должно содержать нулевые байты. Но наше длинное имя пользователя — это
строка, завершающаяся нулём, которая не может содержать нулевые байты;
к счастью, мы вспомнили, что _pam_free_handlers_aux() обнуляет структуры,
которые освобождает (строка 1019 выше): поэтому мы «патчим» поле размера
нашего поддельного блока с помощью такого memset(0), и только потом
освобождаем его.
Нам нужно пережить несколько вызовов free() (в строках 1017 и 1020 выше)
до того, как произойдёт нашего поддельного блока .
Мы превращаем эти в операции бездействия, направляя их на поддельные
блоки : вызывает , который вызывает
, который завершается неудачей, потому что эти поддельные блоки
неправильно выровнены; по сути — бездействие, поскольку ошибки
не приводят к принудительному завершению в glibc этой Ubuntu.
Наконец, наше длинное имя пользователя также позволяет нам контролировать
потенциально неинициализированное поле next 20 различных структур (через
остатки от временных копий нашего длинного имени пользователя), потому что
pam_start() вызывает _pam_add_handler() несколько раз; то есть наше
большое окно гонки содержит 20 маленьких окон гонки.
Те же трюки, что и раньше
-- The Interrupters, "Divide Us"
Для этой атаки против Ubuntu 6.06.1 мы просто повторно использовали стратегию времени, которую применяли против Debian 3.0r6: в среднем требуется ~10 000 попыток, чтобы выиграть состояние гонки, и при 10 соединениях (MaxStartups), принимаемых за 120 секунд (LoginGraceTime), получение удалённой root-оболочки занимает в среднем ~1-2 дня.
Примечание: поскольку glibc этой Ubuntu всегда захватывает обязательную блокировку при входе в функции семейства malloc, невезучий злоумышленник может заблокировать все 10 соединений MaxStartups до получения root-оболочки; мы не пытались обойти эту проблему, потому что нашей конечной целью всё равно была эксплуатация современной версии OpenSSH.
Теперь ты готов, дай отпор демонам
-- The Interrupters, "Be Gone"
Обработчик SIGALRM в этой версии OpenSSH не вызывает ни packet_close(), ни
pam_end(); на самом деле он вызывает только одну интересную функцию:
syslog():
Тогда наши два ключевых вопроса: вызывает ли syslog() из glibc этого Debian
(2.36) функции, небезопасные для асинхронных сигналов, такие как malloc()
и free()? И если да, то по-прежнему ли эта glibc захватывает обязательную
блокировку при входе в функции семейства malloc?
syslog() внутри обработчика SIGALRM является самым первым вызовом
syslog(), то __localtime64_r() (которая вызывается из syslog())
вызывает malloc(304) для выделения структуры FILE (в строке 166) и
вызывает malloc(4096) для выделения внутреннего буфера чтения (в строке
186):Примечание: поскольку мы ничего не контролируем в этих выделениях malloc()
(ни их порядок, ни их размеры, ни их содержимое), мы восприняли "rce" в
строке 166 как крайне необходимое доброе предзнаменование.
И, к счастью для нас, ответ на второй вопрос — нет; начиная с октября 2017
года функции malloc в glibc больше не захватывают никакую блокировку, если
программа однопоточная (как sshd):
https://sourceware.org/git?p=glibc.git;a=commit;h=a15d53e2de4c7d83bda251469d92a3c7b49a90db https://sourceware.org/git?p=glibc.git;a=commit;h=3f6bb8a32e5f5efd78ac08c41e623651cc242a89 https://sourceware.org/git?p=glibc.git;a=commit;h=905a7725e9157ea522d8ab97b4c8b96aeb23df54
Более того, эта версия Debian страдает от уязвимости ASLR, описанной в следующих отличных постах в блогах (соответственно, Джастина Миллера и Матиаса Краузе):
https://zolutal.github.io/aslrnt/ https://grsecurity.net/toolchain_necromancy_past_mistakes_haunting_aslr
Конкретно, в случае с sshd на i386 каждое отображение памяти
рандомизируется нормально (PIE sshd, куча, большинство библиотек, стек),
но сама glibc всегда отображается либо по адресу 0xb7200000, либо по адресу
0xb7400000; иными словами, мы можем правильно угадать адрес glibc в
половине случаев (небольшая цена за обход ASLR). В нашем эксплойте мы
предполагаем, что glibc отображена по адресу 0xb7400000, потому что это
немного более распространённый вариант, чем 0xb7200000.
Наш следующий вопрос: какие пути кода внутри функций malloc в glibc, если
прервать их сигналом SIGALRM в нужный момент, оставляют кучу в
несогласованном состоянии, которое можно использовать во время одного из
вызовов malloc() внутри обработчика SIGALRM?
Мы нашли несколько интересных (и удивительных!) путей кода, но выбранный нами
путь использует только относительные размеры, а не абсолютные адреса (в отличие,
например, от различных путей кода внутри unlink_chunk()); это различие может
оказаться решающим для будущего эксплойта под amd64. Этот путь кода внутри
malloc() разделяет большой свободный блок (жертву) на два меньших блока;
первый блок возвращается вызывающему malloc() (в строке 4345), а второй блок
(остаток) связывается в несортированный список свободных блоков (в строках
4324-4327):
Если этот путь кода будет прерван сигналом SIGALRM после строки 4327, но
до строки 4339, то блок-остаток этого разделения уже будет связан в
несортированный список свободных блоков (строки 4324-4327), но его поле
размера (mchunk_size) ещё не будет инициализировано (строка 4339).
Если мы сможем контролировать его поле размера (через остатки от предыдущих
выделений в куче), то сможем сделать этот блок-остаток больше, чтобы он
перекрывался с другими блоками кучи, и, следовательно, повредить память
кучи, когда этот увеличенный, перекрывающийся блок-остаток в итоге будет
выделен через malloc() и в него будут производиться записи (внутри
обработчика SIGALRM).
Тогда наш последний вопрос: учитывая, что мы ничего не контролируем в вызовах
malloc() внутри обработчика SIGALRM, что мы можем перезаписать в куче,
чтобы добиться выполнения произвольного кода до того, как sshd вызовет
_exit() (в sshsigdie())?
Поскольку __tzfile_read() (внутри обработчика SIGALRM) выделяет структуру
FILE в куче через malloc() (в строке 166 выше), и поскольку структуры
FILE имеют долгую историю злоупотреблений для выполнения произвольного
кода, мы решили направить наше повреждение кучи именно на эту структуру
FILE. Однако это легче сказать, чем сделать: наше повреждение кучи очень
ограничено, а структуры FILE за эти годы были значительно укреплены
(например, с помощью IO_validate_vtable() и PTR_DEMANGLE()).
В итоге мы разработали следующую технику (которая, по-видимому, специфична для
glibc на i386 — в glibc на amd64 _vtable_offset вообще не используется):
с помощью нашего ограниченного повреждения кучи мы перезаписываем поле
_vtable_offset (один знаковый байт) структуры FILE из __tzfile_read();
функции libio в glibc будут поэтому искать указатель на vtable этой
структуры FILE (указатель на массив указателей на функции) по ненулевому
смещению (наше перезаписанное значение _vtable_offset) вместо стандартного
нулевого смещения;
мы (атакующие) можем легко контролировать этот поддельный указатель на
vtable (через остатки от предыдущих выделений в куче), потому что поле
структуры FILE вокруг этого смещения не инициализируется явно вызовом
fopen();
чтобы пройти проверки безопасности glibc, наш поддельный указатель на vtable
должен указывать куда-то в секцию __libc_IO_vtables; мы решили указать
его на vtable для широко-символьных потоков, _IO_wfile_jumps (то есть на
0xb761b740, поскольку мы предполагаем, что glibc отображена по адресу
0xb7400000);
Вкратце: перезаписывая один байт (_vtable_offset) структуры FILE,
выделенной через malloc() вызовом fopen(), мы можем вызвать свой
собственный указатель на функцию __fct и выполнить произвольный код во время
__fread_unlocked().
Я хотел, чтобы было идеально, без изъянов
-- The Interrupters, "In the Mirror"
Чтобы провести эту атаку против привилегированного дочернего процесса sshd,
давайте сначала представим следующую компоновку кучи («XXX» — это «барьерные»
блоки, которые позволяют нам делать дырки в куче; например, небольшие утечки
памяти):
---|----------------------------------------------|---|------------|---
| XXX | большая дыра | XXX | малая дыра | XXX |
|---|---|---|---|---|
| ~8KB | 320Б |
sshd получит SIGALRM, мы выделяем через malloc()
блок размером ~4KB, который разделяет большую дыру ~8KB на два меньших
блока:---|-----------------------|----------------------|---|------------|--- XXX| большой выделенный | свободный блок- |XXX| малая дыра |XXX | блок | остаток | | | ---|-----------------------|----------------------|---|------------|--- | ~4KB | ~4KB | | 320Б |
но если этот malloc() будет прерван сигналом SIGALRM после строки 4327,
но до строки 4339, то блок-остаток этого разделения уже будет связан в
несортированный список свободных блоков, но его поле размера будет под
нашим контролем (через остатки от предыдущих выделений в куче), и этот
искусственно увеличенный блок-остаток будет перекрываться со следующей
малой дырой:---|-----------------------|----------------------|---|------------|---
XXX| большой выделенный кусок | реальный остаточный кусок |XXX| маленькая дырка |XXX
---|-----------------------|----------------------|---|------------|---
| ~4KB |<------------------------------------->|
искусственно увеличенный остаточный кусок
когда обработчик SIGALRM вызывает syslog() и, следовательно, __tzfile_read(), fopen() malloc()атит маленькую дырку для своей структуры FILE, а __fread_unlocked() malloc()атит 4KB буфер чтения, тем самым разделяя увеличенный остаточный кусок на два (4KB буфер чтения и маленький остаточный кусок):
---|-----------------------|----------------------|---|------------|--- XXX| большой выделенный кусок | |XXX| FILE |XXX ---|-----------------------|----------------------|---|--|---------|--- | ~4KB |<--------------------------->|<------->| 4KB буфер чтения остаток
таким образом мы перезаписываем части структуры FILE внутренним заголовком этого маленького остаточного куска: точнее, мы перезаписываем _vtable_offset структуры FILE третьим байтом поля bk этого заголовка, который является указателем на несортированный список свободных кусков, 0xb761d7f8 (т.е. мы перезаписываем _vtable_offset значением 0x61);
затем, как объяснено в подразделе "Теория", __fread_unlocked() вызывает _IO_wfile_underflow() (вместо _IO_file_underflow()), который вызывает наш собственный указатель функции __fct (через наш собственный указатель _codecvt) и выполняет наш произвольный код.
Примечание: мы ещё не объяснили, как надёжно перейти от контролируемого указателя _codecvt к контролируемому указателю функции __fct; мы сделаем это, но сначала должны решить более насущную проблему.
Действительно, мы узнали из нашей работы над старыми версиями OpenSSH, что мы никогда не выиграем эту гонку в обработчике сигнала, если наше большое окно гонки содержит только одно маленькое окно гонки. Следовательно, мы реализовали следующую стратегию, основанную на следующей раскладке кучи:
---|------------|---|------------|---|------------|---|------------|---
| XXX | большая дырка 1 | XXX | маленькая дырка 1 | XXX | большая дырка 2 | XXX | маленькая дырка 2 | ... |
|---|
Последний пакет, который мы отправляем sshd (незадолго до доставки SIGALRM), заставляет sshd выполнить следующую последовательность вызовов malloc(): malloc(~4KB), malloc(304), malloc(~4KB), malloc(304) и т.д.
1/ Наш первый malloc(~4KB) разделяет большую дырку 1 на две:
если это первое разделение прерывается SIGALRM в нужный момент, то fopen() внутри обработчика SIGALRM malloc()атит маленькую дырку 1 для своей структуры FILE, и мы достигаем произвольного выполнения кода, как объяснено выше;
если нет, то мы сами malloc()атим маленькую дырку 1 нашим первым malloc(304), и:
2/ Наш второй malloc(~4KB) разделяет большую дырку 2 на две:
если это второе разделение прерывается SIGALRM в нужный момент, то fopen() внутри обработчика SIGALRM malloc()атит маленькую дырку 2 для своей структуры FILE, и мы достигаем произвольного выполнения кода, как объяснено выше;
если нет, то мы сами malloc()атим маленькую дырку 2 нашим вторым malloc(304) и т.д.
Нам удалось создать 27 пар таких больших и маленьких дырок в куче sshd (28 превысило бы PACKET_MAX_SIZE, 256KB): наше большое окно гонки теперь содержит 27 маленьких окон гонки! Достижение такой сложной раскладки кучи было чрезвычайно болезненным и затратным по времени, но два основных момента:
Чтобы надёжно достичь этой раскладки кучи, мы отправляем sshd пять различных пакетов открытых ключей (пакеты a/ - d/ можно отправить задолго до SIGALRM; большую часть пакета e/ также можно отправить задолго до SIGALRM, но его самый последний байт должен быть отправлен в самый последний момент):
a/ Мы malloc()атим и free()им множество tcache-кусков, чтобы гарантировать, что выделения кучи, которые мы не контролируем, попадут в эти tcache-куски и не помешают нашей тщательной раскладке кучи.
b/ Мы malloc()атим и free()им куски различных размеров, чтобы создать наши 27 пар больших и маленьких дырок (и соответствующие "барьерные" куски).
c/ Мы malloc()атим и free()им куски размером ~4KB и 320B, чтобы:
записать фальшивый заголовок (большое поле размера) нашего потенциально увеличенного остаточного куска в середину наших больших дырок;
записать фальшивый нижний колонтитул нашего потенциально увеличенного остаточного куска в конец наших маленьких дырок (чтобы пройти проверки безопасности glibc);
записать наши фальшивые указатели vtable и _codecvt в наши маленькие дырки (которые являются потенциальными структурами FILE).
d/ Мы malloc()атим и free()им одну очень большую строку (почти 256KB), чтобы гарантировать, что наши большие и маленькие дырки будут удалены из несортированного списка свободных кусков и помещены в соответствующие бины malloc.
e/ Мы заставляем sshd выполнить нашу финальную последовательность вызовов malloc (malloc(~4KB), malloc(304), malloc(~4KB), malloc(304) и т.д.), чтобы открыть наши 27 маленьких окон гонки.
Внимательные читатели могли заметить, что мы всё ещё не решили (буквально и фигурально) проблему _codecvt. На самом деле, _codecvt - это указатель на структуру (_IO_codecvt), которая содержит указатель на структуру (__gconv_step), которая содержит указатель функции __fct, который позволяет нам выполнять произвольный код. Чтобы надёжно контролировать __fct через _codecvt, мы просто указываем _codecvt на один из бинов malloc glibc, который удобно содержит указатель на один из наших свободных кусков в куче, который содержит наш собственный указатель функции __fct на произвольный код glibc (все эти адреса glibc нам известны, потому что мы предполагаем, что glibc отображён по адресу 0xb7400000).
Время на исходе
-- The Interrupters, "As We Live"
Когда мы реализовывали этот третий эксплойт, стало ясно, что мы не можем просто повторно использовать стратегию времени, которую мы использовали против двух старых версий OpenSSH: мы никогда не выигрывали это новое состояние гонки. В конце концов мы поняли, почему:
Требуется много времени (~10ms) для sshd на разбор нашего пятого и последнего открытого ключа (пакет e/ выше); другими словами, наше большое окно гонки слишком велико (наши 27 маленьких окон гонки - как иголки в стоге сена).
user_specific_delay(), которая была введена недавно (OpenSSH 7.8p1), задерживает ответ sshd на наш последний пакет открытого ключа до ~9ms и, следовательно, разрушает нашу стратегию времени, основанную на обратной связи.
В результате мы разработали совершенно другую стратегию времени:
время от времени мы отправляем наш последний пакет открытого ключа с небольшой ошибкой, которая приводит к ответу об ошибке (строки 138-142 ниже), прямо перед вызовом sshkey_from_blob(), который разбирает наш открытый ключ;
время от времени мы отправляем наш последний пакет открытого ключа с другой небольшой ошибкой, которая приводит к ответу об ошибке (строки 151-155 ниже), сразу после вызова sshkey_from_blob(), который разбирает наш открытый ключ;
разница между этими двумя временами ответа - это время, которое требуется sshd на разбор нашего последнего открытого ключа, и это позволяет нам точно рассчитать время передачи наших последних пакетов (чтобы гарантировать, что sshd успеет разобрать наш открытый ключ в непривилегированном дочернем процессе, отправить его привилегированному дочернему процессу и начать разбор там до доставки SIGALRM).
С этим изменением стратегии в среднем требуется ~10 000 попыток, чтобы выиграть гонку; т.е. при 100 соединениях (MaxStartups), принятых за 120 секунд (LoginGraceTime), в среднем требуется ~3-4 часа, чтобы выиграть гонку, и ~6-8 часов, чтобы получить удалённую root-оболочку (из-за ASLR).
Какие у тебя планы на завтра?
-- The Interrupters, "Take Back the Power"
Мы решили нацелиться на Rocky Linux 9 (производный от Red Hat Enterprise Linux 9), из "Rocky-9.4-x86_64-minimal.iso", по двум причинам:
его версия OpenSSH (8.7p1) уязвима для этой гонки в обработчике сигнала, а его glibc всегда отображена по адресу, кратному 2MB (из-за слабости ASLR, обсуждавшейся в предыдущем подразделе "Теория"), что делает частичные перезаписи указателей гораздо более мощными;
функция syslog() (которая небезопасна для async-signal, но вызывается обработчиком SIGALRM в sshd) в этой версии glibc (2.34) внутренне вызывает __open_memstream(), который malloc()атит структуру FILE в куче, а также вызывает calloc(), realloc() и free() (что даёт нам некоторую столь необходимую свободу).
Имея повреждение кучи в качестве примитива, две структуры FILE, выделенные malloc() в куче, и 21 фиксированный бит в адресах glibc, мы полагаем, что эта гонка в обработчике сигнала эксплуатируема на amd64 (вероятно, не за ~6-8 часов, но, надеюсь, менее чем за неделю). Время покажет.
Побочное замечание: мы обнаружили, что Ubuntu 24.04 не перерандомизирует ASLR своих дочерних процессов sshd (рандомизируется только один раз, при загрузке); мы отследили это до патча ниже, который отключает флаг rexec_flag у sshd. В целом это плохая идея, но в конкретном случае этой гонки в обработчике сигнала это предотвращает эксплуатацию sshd: syslog() внутри обработчика SIGALRM не вызывает никаких функций malloc, потому что это никогда не самый первый вызов syslog().
https://git.launchpad.net/ubuntu/+source/openssh/tree/debian/patches/systemd-socket-activation.patch
Шторм пришёл и ушёл
-- The Interrupters, "Good Things"
6 июня 2024 года эта гонка в обработчике сигнала была исправлена коммитом 81c1099 ("Add a facility to sshd(8) to penalise particular problematic client behaviours"), который переместил небезопасный для async-signal код из обработчика SIGALRM в sshd в процесс-слушатель sshd, где его можно обрабатывать синхронно:
https://github.com/openssh/openssh-portable/commit/81c1099d22b81ebfd20a334ce986c4f753b0db29
Поскольку это исправление является частью большого коммита (81c1099), поверх ещё большего коммита защиты в глубину (03e3de4, "Start the process of splitting sshd into separate binaries"), его может быть трудно портировать обратно. В этом случае саму гонку в обработчике сигнала можно исправить, удалив или закомментировав небезопасный для async-signal код из функции sshsigdie(); например:
sshsigdie(const char *file, const char *func, int line, int showfunc, LogLevel level, const char *suffix, const char *fmt, ...) { #if 0 va_list args;
va_start(args, fmt);
sshlogv(file, func, line, showfunc, SYSLOG_LEVEL_FATAL,
suffix, fmt, args);
va_end(args);
Наконец, если sshd не может быть обновлён или перекомпилирован, эту гонку в обработчике сигнала можно исправить, просто установив LoginGraceTime в 0 в файле конфигурации. Это делает sshd уязвимым для отказа в обслуживании (истощение всех соединений MaxStartups), но делает его безопасным от удалённого выполнения кода, представленного в этом уведомлении.
Мы благодарим разработчиков OpenSSH за их выдающуюся работу и тесное сотрудничество над этим выпуском. Мы также благодарим distros@openwall. Наконец, мы посвящаем это уведомление Софии д'Антуан.
2024-05-19: Мы связались с разработчиками OpenSSH. Последовали итерации патчей и их рецензий.
2024-06-20: Мы связались с distros@openwall.
2024-07-01: Дата скоординированного выпуска.
free()NON_MAIN_ARENAfree()IS_MMAPPEDfree()munmap_chunk()munmap()IS_MMAPPEDassert()в результате __fread_unlocked() (в строке 186 выше) вызывает
_IO_wfile_underflow() (вместо _IO_file_underflow()), которая вызывает
указатель на функцию (__fct), который в основном берётся из структуры,
указатель на которую (_codecvt) является ещё одним полем структуры FILE;
мы (атакующие) можем легко контролировать этот указатель _codecvt (через
остатки от предыдущих выделений в куче, поскольку это поле структуры FILE
не инициализируется явно вызовом fopen()), что также позволяет нам
контролировать указатель на функцию __fct.
| ~8KB | 320B | ~8KB | 320B |