Когда я обсуждал, почему мы не делаем весь стек из защитных страниц, комментатор BCS поинтересовался: «Зачем требовать, чтобы стек использовал непрерывно отображаемые страницы? Что сломается, если маппироваться будут только затронутые страницы? Это может быть даже полезно, например, для функции, которая хочет alloca 512 МБ на стеке, но читает/пишет лишь несколько страниц.»
Итак, вопрос: почему стек должен быть непрерывным? Почему бы не позволить ему быть разреженным и подгружать в память только те страницы, которые были затронуты?
Первая проблема в том, что код проверки стека должен был бы включать явную проверку предела стека, вместо простого спуска по стеку по одному странице за раз. Эта явная проверка нужна, чтобы избежать уязвимостей безопасности, если кто-то сумеет сделать alloca с буфером настолько большим, что он полностью выйдет за пределы резервируемой области стека. Если идти по одной странице за раз, вы в конечном итоге упираетесь в страницу с запретом доступа, которая отмечает конец стека. Но если можно перепрыгивать сразу через несколько страниц, не затрагивая их, то можно перепрыгнуть настолько далеко за пределы стека, что оказаться в другом месте и начать портить ту память, используя её в качестве стека. В кругах Linux эту уязвимость прозвали «Stack Clash», а формально её называют «прыжки через защитную страницу стека» (stack guard-page hopping).
После исправления этой проблемы возникает другая: как сообщить о неудаче при коммите страницы в середине стека?
Если позволить разреженные стеки, то память для буфера фактически не будет зарезервирована до тех пор, пока код её не использует. Но точка использования буфера находится вне обработчика исключений для неудачного alloca(). Код разумно предполагает, что если alloca вернул успех, то память действительно выделена.
Можно было бы попытаться исправить это, выполняя коммит памяти без её отображения как присутствующей. Это означало бы вызов VirtualAlloc для расширения стека вместо простого обращения к памяти. Это не только усложнило бы код расширения стека, особенно поскольку необходимо сохранить все регистры, которые могут быть использованы любым соглашением о вызовах, но и нужно убедиться, что сама функция VirtualAlloc не выделит слишком много стека!
Тем не менее можно настроить пробер стека для x86-32 так, чтобы избежать проблемы pete.d, когда большой фрейм стека делается полностью присутствующим, и последующие подгрузки страниц вызывают заметные падения производительности. Пробер x86-32 может прервать проверку стека (как это делают MIPS и другие процессоры, перечисленные в соответствующей таблице), чтобы подгрузки страниц происходили только тогда, когда стек действительно расширяется.
Смягчение эффекта Stack Clash в GCC — предыстория
Смягчение Stack Clash в GCC: почему -fstack-check не является ответом.
Смягчение Stack Clash в GCC, часть 3.
Некоторые системы пытаются смягчить проблему прыжков через защитную страницу стека, создавая очень большую область с запретом доступа за концом стека. Однако это не решение; лишь частичное смягчение. Оно просто заставляет атакующего перепрыгивать дальше, чтобы преодолеть зону с запретом доступа. Если уязвимость уже присутствует, вероятно, потому что атакующий может контролировать размер выделения, в этом случае вы особо не замедлили его; ему просто нужно положить большее число в полезную нагрузку атаки.
Другие системы решают это более основательно, (о, сюрприз) пробуя каждую страницу стека последовательно.
Проблема Stack Clash продолжает существовать, хотя у gcc было решение ещё в 2020 году. Вот CVE-2026-77658 всего за несколько дней до этого.
Рэймонд участвует в эволюции Windows более 30 лет. В 2003 году он завёл веб-сайт, известный как The Old New Thing, который стал популярнее, чем он мог представить, что до сих пор причиняет ему дрожь. Сайт породил книгу с тем же названием The Old New Thing (Addison Wesley, 2007). Он изредка появляется в аккаунте Windows Dev Docs в Twitter, рассказывая истории, которые не передают полезной информации.
Обсуждение закрыто. Войдите в систему, чтобы редактировать/удалять существующие комментарии.
Существует четыре основных класса отказа выделения:
- Фрагментация: нет достаточно большого адресного блока.
- Фактическое использование всей адресуемой памяти (не фрагментация).
- Ошибочное большое выделение, например выделение «-1» байта.
Последний случай — серьёзная ошибка, которую нужно исправлять, поэтому следует немедленно дампить core — даже если затем процесс откатится и продолжит работу.
В наших 32‑битных приложениях мы раньше пытались обрабатывать первые два случая, по крайней мере настолько, чтобы сохранить работу пользователя перед перезапуском, потому что обычная причина отказа выделения — фрагментированная куча, а не реальная нехватка памяти.
В 64‑битных системах первые два почти невозможны, потому что адресное пространство x64 настолько велико, — но фактическое исчерпание виртуальной памяти стало относительно «обычным». С этим сложнее справляться, потому что приложение в такой момент очень медленно работает.
Как правило, интерактивное приложение, которое близко к исчерпанию виртуальной памяти, настолько тормозит из‑за интенсивного свопинга, что пользователь просто завершит его. (MS Defender раньше часто попадал в такую ситуацию.)
На безголовых серверах это не так. Пользователи заметят замедление отклика, но обычно не могут даже сообщить оператору о проблеме.
Существует направление мысли «просто быстро упасть», предполагающее, что серверы вообще не должны иметь виртуальной памяти, чтобы они падали и перезапускались до того, как станут неотзывчивыми.
Замечание об обработке ошибок в принципе справедливо (и на самом деле требует внимания со стороны разработчиков ОС и сред выполнения), но интересно, сколько программ на практике действительно могут осмысленно продолжить работу после сбоя при выделении памяти? Я подозреваю, что таких немного. (Что, в свою очередь, означает, что игнорирование этой проблемы должно быть опцией, а не поведением по умолчанию.)
Мне известно по крайней мере одно кодексное требование, которое по сути говорит: позволять просто падать, если выделение не удалось, но это в контексте «все коровы, никаких питомцев», где терпимо, что вычислители исчезают без предупреждения, поэтому падение процесса со стек‑трейсом считается приемлемым.
«вам нужно сохранить все регистры, которые могут быть использованы любым соглашением о вызовах»
Это не проблема. Используйте pusha/popa на x86; иначе просто сохраните все регистры. Вам и так это нужно из‑за соглашений о вызовах по регистрам.
«Функция VirtualAlloc сама по себе не должна выделять слишком много стека!»
Она не должна использовать слишком много стека. Это обёртка для системного вызова. Если она использует больше килобайта стека, это серьёзная проблема. Хотя для alloca() это не совсем релевантно; ядро управляет непрерывным расширением стека, так что если VirtualAlloc не вызывает alloca или _chkstk, проблем не возникает.
Уведомлять о новых публикациях.
Вы уверены, что хотите удалить это?
