Что значит использование неинициализированной памяти в с

Содержание
  1. Проклятие неинициализированных переменных
  2. Проблема
  3. Почему ноль?
  4. Решение
  5. Использование неинициализированной памяти
  6. 1.6 – Неинициализированные переменные и неопределенное поведение
  7. Неинициализированные переменные
  8. Неопределенное поведение
  9. Небольшой тест
  10. Как мы решаем проблему неинициализированной стековой памяти в Windows
  11. Работа с неинициализированной памятью: история проблемы
  12. Пример использования неинициализированной памяти
  13. Пример раскрытия содержимого неинициализированной памяти
  14. Статистика по ошибкам, связанным с неинициализированной памятью
  15. Дополнительная литература
  16. Способы устранения уязвимостей, связанных с неинициализированной памятью
  17. Статический анализ
  18. Фаззинг
  19. Обзор кода
  20. InitAll – Автоматическая инициализация
  21. Текущие настройки Windows:
  22. Как мы избегаем проблемы «разветвления языка»
  23. Как мы выбираем, для каких компонентов задействовать InitAll
  24. Ломает ли InitAll статический анализ?
  25. Почему мы инициализируем не все типы
  26. Почему мы инициализируем переменные нулём
  27. С позиции безопасности
  28. С позиции производительности
  29. Пример 1: Инициализация с использованием регистров общего назначения
  30. Пример 2: Инициализация с использованием XMM-регистров
  31. Интересные наблюдения, связанные с применением InitAll
  32. Производительность
  33. Совместимость
  34. Античиты
  35. Использование освобождённой памяти в FAT32
  36. Оптимизации производительности
  37. Отключение InitAll для критического кода
  38. Удаление лишних операций записи
  39. Удаление нескольких memset
  40. Уменьшение размера memset
  41. Более эффективная развёртка memset
  42. Значение для пользователей
  43. Планы на будущее

Проклятие неинициализированных переменных

Позволять программистам использовать неинициализированные переменные — большая ошибка со стороны разработчиков языка. Например, это может привести к значению undefined в JavaScript, которое чревато сопутствующими ошибками.

Такую оплошность легко совершить и тяжело отследить. Особенно при выполнении программы на разных платформах. И необходимости в этой особенности нет — переменная всегда должна иметь определенное значение.

Проблема

Локальные переменные, переменные-поля и т. д. представляют собой неинициализированные переменные, то есть в них будет записано ровно то, что было записано в отведенной под них памяти при объявлении. В C++ существуют различные правила для работы с неинициализированными переменными, инициализированными переменными и нуль-инициализированными переменными. Очень путанный механизм.

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

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

Почему ноль?

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

Естественно, нет никакого правила, чем заполнять память. Вероятно, OpenBSD делает это как-то иначе. Иначе чистит память и ArchLinux, запущенный в VirtualBox. Этим может заниматься не только операционная система — то же может проделать и другая программа, например. И если вдруг в область памяти, которую использует приложение, попадут какие-нибудь значения, изменить их сможет уже только сама эта программа.

Любопытно, что это стало одной из причин появления Heartbleed бага.

Решение

Язык просто не должен позволять использовать неинициализированные переменные. Необязательно нужно указывать конкретное значение — значение просто обязательно должно быть. Например, значением по умолчанию может стать все тот же ноль. Вне зависимости от того, как и в какой области видимости я создал данную переменную.

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

Автор статьи уверен, что уже в ближайшем стандарте C++ должны быть реализованы эти изменения. Превращение неинициализированных ранее переменных в инициализированные не повлияет на корректность выполнения ни одной программы. Такое нововведение будет полностью backwards compatible, и серьезно улучшит популярный язык.

Источник

Использование неинициализированной памяти

Ошибка, строка 79:warning C6385:
Чтение недопустимых данных из «incidenceMatrix»: доступный для чтения объем равен «m*8» байт, однако считать можно только «16» байт.

Однако! Если задать память вручную прописать
( int** incidenceMatrix = new int* [5];
incidenceMatrix[row] = new int[5]; )
то ошибка превращается в :
warning C6001: Использование неинициализированной памяти «*incidenceMatrix».

Подскажите как с этим бороться и как исправить

Помощь в написании контрольных, курсовых и дипломных работ здесь.

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

Использование неинициализированной памяти
В строке 120 вылезает ошибка «Использование неинициализированной памяти *st», возможно из-за этого.

Использование неинициализированной памяти dword
#include «iostream» #include «stdio.h» #include «string.h» #include «windows.h» using.

Откуда вы берёте эту хрень? Один и тот же код с тупой ошибкой, которую не заметит только тот, кто вообще не одупляет, что там происходит.

Я не знаю где другие его берут но я парюсь над этим заданием уже 1.5 месяца.
И только после месяца самостоятельного поиска решения я обратиться за помощью на форум.

Если бы я понимал что тут происходит я бы сюда не обращался. По моему логично

Источник

1.6 – Неинициализированные переменные и неопределенное поведение

Неинициализированные переменные

В отличие от некоторых языков программирования, C/C++ не инициализирует большинство переменных автоматически заданным значением (например, нулем). Таким образом, когда компилятор выделяет переменной место в памяти, значением по умолчанию для этой переменной является любое (мусорное) значение, которое уже находится в этой области памяти! Переменная, которой не было присвоено известное значение (обычно посредством инициализации или присваивания), называется неинициализированной переменной.

Примечание автора

Многие читатели ожидают, что термины «инициализированный» и «неинициализированный» будут строго противоположными, но это не совсем так! Инициализация означает, что объекту было предоставлено начальное значение в точке определения. Неинициализированный означает, что объекту не было присвоено известное значение (каким-либо образом, включая присваивание). Следовательно, объект, который не инициализирован, но которому затем было присвоено значение, больше не является неинициализированным (потому что ему было присвоено известное значение).

В качестве отступления.

Отсутствие инициализации является оптимизацией производительности, унаследованной от C, когда компьютеры были медленными. Представьте себе случай, когда вы собираетесь прочитать 100 000 значений из файла. В таком случае вы можете создать 100 000 переменных, а затем заполнить их данными из файла.

Если бы C++ инициализировал все эти переменные при создании значениями по умолчанию, это привело бы к 100 000 инициализаций (что было бы медленно) и к небольшой выгоде (поскольку вы всё равно перезапишете эти значения).

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

Использование значений неинициализированных переменных может привести к неожиданным результатам. Рассмотрим следующую короткую программу:

В качестве отступления.

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

Если ваш компилятор по этой причине не позволяет вам скомпилировать и запустить приведенную выше программу, вот возможное решение этой проблемы:

Использование неинициализированных переменных – одна из наиболее распространенных ошибок, которые совершают начинающие программисты, и, к сожалению, она также может быть одной из самых сложных для отладки (потому что программа всё равно может работать нормально, если неинициализированное значение было присвоено определенной области памяти, которая содержала приемлемое значение, например, 0).

Это основная причина использования оптимальной практики «всегда инициализировать переменные».

Неопределенное поведение

Использование значения из неинициализированной переменной – наш первый пример неопределенного поведения. Неопределенное поведение – это результат выполнения кода, поведение которого не определено языком C++. В этом случае в языке C++ нет правил, определяющих, что произойдет, если вы используете значение переменной, которой не было присвоено известное значение. Следовательно, если вы действительно сделаете это, результатом будет неопределенное поведение.

Код, реализующий неопределенное поведение, может проявлять любые из следующих симптомов:

Или ваш код в любом случае может действительно вести себя правильно. Природа неопределенного поведения заключается в том, что вы никогда не знаете точно, что получите, будете ли вы получать это каждый раз, и изменится ли это поведение, когда вы внесете какие-либо изменения.

C++ содержит множество случаев, которые могут привести к неопределенному поведению, если вы не будете осторожны. Мы будем указывать на них в будущих уроках всякий раз, когда с ними столкнемся. Обратите внимание на эти случаи и убедитесь, что вы их избегаете.

Правило

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

Примечание автора

Один из наиболее распространенных типов комментариев, которые мы получаем от читателей, гласит: «Вы сказали, что я не могу делать X, но я всё равно сделал это, и моя программа работает! Почему?».

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

В качестве альтернативы, иногда авторы компиляторов допускают вольность к требованиям языка, когда эти требования могут быть более строгими, чем необходимо. Например, в стандарте может быть сказано: «Вы должны сделать X перед Y», но автор компилятора может счесть это ненужным и заставить Y работать, даже если вы сначала не выполните X. Это не должно влиять на работу правильно написанных программ, но в любом случае может привести к тому, что неправильно написанные программы будут работать. Таким образом, альтернативный ответ на вышеупомянутый вопрос заключается в том, что ваш компилятор может просто не следовать стандарту! Такое случается. Вы можете избежать этого, если отключили расширения компилятора, как описано в уроке «0.10 – Настройка компилятора: расширения компилятора».

Небольшой тест

Вопрос 1

Что такое неинициализированная переменная? Почему вам следует избегать их использования?

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

Вопрос 2

Что такое неопределенное поведение и что может произойти, если вы сделаете что-то, что демонстрирует неопределенное поведение?

Неопределенное поведение – это результат выполнения кода, поведение которого не определяется языком. Результатом может быть что угодно, в том числе и то, что ведет себя правильно.

Источник

Как мы решаем проблему неинициализированной стековой памяти в Windows

В этой заметке я расскажу в общих чертах о том, как в Microsoft устраняют уязвимости, связанные с неинициализированной стековой памятью, и почему мы вообще этим занимаемся.

Для удобства навигации заметка разбита на разделы:

Работа с неинициализированной памятью: история проблемы

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

Уязвимости, связанные с неинициализированной памятью, сводятся к двум типам:

Пример использования неинициализированной памяти

Проблема здесь в том, что если функция GetSize не присвоит значение переменной ‘size’ во всех ветках программы, то в вызов memcpy будет передан неинициализированный размер. Из-за этого может возникнуть ошибка чтения или записи за пределами буфера, если значение ‘size’ окажется больше, чем размер буфера ‘src’ или ‘dest’.

Пример раскрытия содержимого неинициализированной памяти

Допустим, что функция memcpy копирует структуру за пределы доверенной области (т.е. из режима ядра в режим пользователя). На первый взгляд кажется, что структура инициализирована полностью, однако между ‘field1’ и ‘field2’ компилятор вставил байты-заполнители, которые не были инициализированы явным образом.

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

Статистика по ошибкам, связанным с неинициализированной памятью

image loader

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

В последние годы число таких ошибок растёт. Вероятно, отчасти это объясняется ростом интереса к ним со стороны исследователей и, как следствие, появлением эффективных инструментов для их поиска.

Более подробная классификация этих ошибок выявляет ещё несколько интересных тенденций.

image loader

Примечание: на этой диаграмме использование неинициализированной памяти НЕ включает в себя раскрытие её содержимого.

image loader

Глядя на эти диаграммы, можно сделать следующие выводы:

Дополнительная литература

Для более полного ознакомления с темой см. следующие ресурсы:

Способы устранения уязвимостей, связанных с неинициализированной памятью

Описанные проблемы пытались решить несколькими способами.

Статический анализ

В Microsoft используются многочисленные предупреждения статического анализатора для отлова неинициализированных переменных (в том числе C4700, C4701, C4703, C6001, C26494 и C26495). Эти диагностики консервативны, т.е. в целях снижения шума они игнорируют некоторые паттерны, которые могут привести к работе с неинициализированной памятью.

Также был написан ряд жёстких правил для статического анализатора Semmle, которые прогоняются на некоторых кодовых базах Windows. Но эти диагностики дают много шума и ими тяжело проверять большие объёмы кода. К тому же соблюдение этих правил и исправление ошибок весьма трудоёмко. В итоге оказалось, что применять их затруднительно и дорого.

Фаззинг

Фаззинг, как известно, плохо поддаётся масштабированию. Хорошие фаззеры затратны в сопровождении и требуют настройки под конкретные задачи. С кодовой базой таких размеров, как у Microsoft, весьма непросто обеспечить полное её покрытие фаззингом.

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

Обзор кода

Обзор кода не масштабируется и чрезвычайно подвержен ошибкам. Код с уязвимостями проходит обзор, однако они настолько хорошо замаскированы, что программисты их не замечают.

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

InitAll – Автоматическая инициализация

Помимо упомянутых подходов, Microsoft с некоторых пор использует механизм под названием InitAll – он автоматически инициализирует стековые переменные на этапе компиляции.

В этом разделе я расскажу, как данная технология применяется в Windows и почему именно таким образом.

Текущие настройки Windows:

Автоматически инициализируются следующие типы:

В отладочных (CHK) сборках или сборках для разработчиков (т.е. неоптимизированных розничных) используется значение 0xE2; числа с плавающей запятой инициализируются значением 1.0.

InitAll применяется к следующим компонентам:

Как мы избегаем проблемы «разветвления языка»

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

При инициализации нулём указатель, который не был корректно инициализирован программистом, может попасть в ветку NULL pointer. В результате вы можете получить программу, которая не падает, но и не выдаёт нужные результаты. Если же инициализировать указатель мусорным значением, он не попадёт в ветку NULL pointer и при попытке использовать его приведёт к падению программы.

Эту проблему мы решаем использованием ненулевого значения инициализации (0xE2) в CHK-сборках и так называемых сборках для разработчиков, которые зачастую представляют собой неоптимизированные релизные сборки. За счёт этого, с одной стороны, удаётся сохранить высокую производительность кода, поставляемого клиентам, а с другой – получить в сборках, находящихся на тестировании, такое поведение, при котором легче заметить пропущенные инициализации.

Замечу, что C++ и так требует автоматической инициализации нулём всех статических членов. Эта семантика помогает разработчикам. Например, увидев статическую переменную с нулевым значением, вы будете знать, что необходимо инициализировать её, так как это первое её использование. InitAll вводит похожую семантику для автоматических (стековых) переменных с одной важной оговоркой: мы стараемся не привязывать разработчиков к конкретным начальным значениям.

Как мы выбираем, для каких компонентов задействовать InitAll

Изначально InitAll планировали использовать на двух компонентах:

Причина, по которой мы не развёртываем InitAll сразу на всём коде, заключается в том, что мы хотим сначала сделать хорошо хоть что-то, а не потерпеть неудачу, пытаясь сделать всё сразу. Чем больше кода мы обрабатываем InitAll за раз, тем труднее отлаживать падения производительности, решать проблемы совместимости и т.д. Теперь, когда мы успешно развернули технологию на самых важных компонентах, можно заняться остальным кодом.

Ломает ли InitAll статический анализ?

Статический анализ чрезвычайно полезен тем, что напоминает разработчикам о переменных, которые они забыли инициализировать перед использованием.

InitAll уведомляет как анализатор PREfast, так и бэкэнд компилятора (оба выдают предупреждения о неинициализированных переменных) о добавленных им инициализациях. Благодаря этому статические анализаторы могут игнорировать такие места и по-прежнему выдавать свои предупреждения. При включённом InitAll вы всё равно будете получать сообщения статического анализатора о неинициализированных переменных – даже если InitAll инициализировал их за вас.

Почему мы инициализируем не все типы

Во время предварительных тестов мы принудительно инициализировали все типы данных, выделяемых на стеке, и наблюдали падения производительности более чем на 10% в нескольких важных сценариях.

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

Мы планируем вернуться к идее инициализации всех типов (особенно теперь, когда у нас появились более мощные оптимизации), просто ещё не дошли до этого.

Почему мы инициализируем переменные нулём

Инициализация нулём даёт наилучшие результаты с точки зрения производительности (как по скорости работы, так и по размеру двоичного кода), а также с точки зрения безопасности.

С позиции безопасности

Инициализация нулём имеет следующие преимущества:

С позиции производительности

От выбранного значения инициализации также зависят скорость работы программы и размер кода. Мы не измеряли, насколько хуже результаты при использовании ненулевого значения, так как нас в первую очередь интересовали преимущества в безопасности, которые даёт инициализация нулём, и мы знали, что заодно она положительно скажется на производительности (как скорости работы, так и размере кода). Наши коллеги из Google провели измерения и показали, что на Clang инициализация нулём на данный момент заметно выгоднее, чем инициализация ненулевым значением.

Ниже я на примерах покажу, почему при инициализации нулём получается меньше кода.

Пример 1: Инициализация с использованием регистров общего назначения

Инициализация ненулевым значением:

В этом примере нас интересуют два момента:

Во-первых, установка регистра RAX в ноль занимает 2 байта кода против 10 байт при установке в ненулевое значение. Получается выигрыш как по размеру кода, так и по скорости работы. Многие процессоры считывают команды по 16 байт за раз, поэтому запись в регистр фиксированной константы с помощью команды размером 10 байт препятствует выдаче следующих команд, которые могли бы выполняться параллельно.

Во-вторых, прежде чем станет возможным записать значение в регистр RCX, придётся дождаться завершения записи в RAX, что может привести к простаиванию процессора. Последовательности вроде «xor eax, eax» распознаются на самых ранних участках конвейера, и реального выполнения команды XOR не требуется – процессоры просто обнуляют регистр RAX. В результате конвейер простаивает меньше времени и программа работает быстрее.

Пример 2: Инициализация с использованием XMM-регистров

Для записи более крупных значений компилятор, как правило, использует XMM-регистры (а также YMM или ZMM в зависимости от того, включена ли поддержка наборов инструкций AVX или AVX512). Как правило, за один такт процессоры могут завершить не более одной команды записи, поэтому будет разумно использовать такие команды, которые устанавливают как можно больше байт.

Инициализация ненулевым значением (загружается из глобальной переменной, что компиляторы обычно и делают):

Инициализация ненулевым значением (загружается из фиксированной константы в коде, чего компиляторы не делают):

Как видим, в случае XMM-регистров наблюдается та же картина. При инициализации нулём код получается совсем небольшим.

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

Чтобы избежать этого, компиляторы, как правило, сохраняют фиксированную константу в виде глобальной переменной, из которой могут потом считать значение, – так получается гораздо меньше кода. К сожалению, придётся дождаться окончания записи в XMM-регистр, прежде чем он станет доступен для использования. Если глобальная переменная будет выгружена из памяти, операция может занять несколько тысяч тактов. На операцию чтения уходит несколько тактов даже при самом хорошем сценарии, когда данные хранятся в кэше L1. И даже в этом случае код получается намного длиннее, чем если просто обнулить регистр.

Тут обнаруживается ещё одно преимущество инициализации нулём: более детерминированные результаты. Время инициализации не зависит от того, находится ли глобальная переменная в кэше L1, L2 или L3, выгружается ли из памяти, и т.д.

Интересные наблюдения, связанные с применением InitAll

Производительность

Windows 10 1903 (выпущена весной 2019 года) стала первой версией, в которой InitAll был включён по умолчанию. До сих пор никаких жалоб на снижение производительности из-за него мы не получали.

Совместимость

Античиты

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

InitAll добавил в начало этих функций дополнительные инициализации (избыточность которых нельзя было доказать), из-за чего их сигнатуры изменились. Мы связались с компаниями-разработчиками этих античитов, и они по нашей просьбе обновили свои драйверы, чтобы те больше не вызывали падений ядра.

Использование освобождённой памяти в FAT32

Вскоре после включения InitAll для скалярных типов данных (т.е. целых чисел, чисел с плавающей запятой и т.д.) мы столкнулись с интересной проблемой в драйвере файловой системы FAT, которая не давала обновлять внутренние сборки Windows с загрузочных USB-флешек.

Код, в котором возникла проблема, выглядел примерно так:

Имеется цикл, внутри которого объявляется переменная. На первой итерации цикла функция DoStuff инициализирует переменную ‘tmp’, адрес которой передаётся ей в качестве аргумента. На каждой последующей итерации переменная ‘tmp’ используется как входной/выходной параметр. Другими словами, её значение сначала считывается, а затем обновляется.

Проблема в том, что рассматриваемая переменная в начале каждой итерации цикла входит в его область видимости, а в конце итерации покидает её. InitAll инициализирует эту переменную нулём перед каждой итерацией. Фактически мы получаем уязвимость, связанную с использованием освобождённой памяти (use-after-free). Для нормальной работы кода требуется, чтобы переменная ‘tmp’ сохраняла своё значение на каждой итерации, даже если в конце итерации она выходит из области видимости. К сожалению, эта проблема приводила не к падению драйвера, а к некорректной логике его работы и, как следствие, непредсказуемому поведению файловой системы. В ходе отладки команда, занимающаяся ядром, определила причину проблемы и исправила её, вынеся объявление переменной за пределы цикла.

Этот случай – наглядный пример того, как улучшения безопасности могут сломать код, в который не заглядывали годами.

Оптимизации производительности

Оптимизации производительности, осуществляемые InitAll, преследуют три цели:

Отключение InitAll для критического кода

Структура _CONTEXT имеет размер более 1000 байт, и этого достаточно, чтобы хранить значения всех регистров. С включённым ETW-логированием для отслеживания переключений контекста каждый раз при смене контекста значения всех регистров заносятся в лог. Структура _CONTEXT в этом случае будет выделяться на стеке, заполняться ассемблерной функцией и затем передаваться в ETW. Из-за того, что структура инициализируется ассемблерной функцией, компилятор не может убрать инициализацию, сделанную InitAll. Поскольку эта структура и так содержит критические данные (состояние каждого регистра), имеет большой размер и используется в чрезвычайно требовательных к производительности ветках, мы решили не применять к ней InitAll.

Для всех остальных типов, переменных и функций InitAll не отключалась.

Удаление лишних операций записи

Удаление лишних операций записи – это оптимизация, выполняемая компилятором Visual Studio, при которой убираются такие операции записи, избыточность которых может быть доказана.

Ниже приводятся примеры разных видов оптимизации, применяемых Visual Studio.

Удаление нескольких memset

Следующий паттерн кода (с разными вариациями) чрезвычайно распространён. Первоначальные правила программирования под NT требуют, чтобы все переменные объявлялись в начале функции, а инициализировались как можно позже. В результате мы имеем случаи, когда переменная объявляется в начале функции, а инициализируется только в какой-нибудь одной ветке непосредственно перед использованием.

InitAll добавляет свою инициализацию переменной в начале функции. Компилятор может удалить дубликат, но это не всегда легко сделать.

image loader

Кажется, что этот простой пример должен легко оптимизироваться, однако GCC 9.3 и Clang 10.0.0 (самые свежие версии, доступные на Godbolt) неспособны в этом случае убрать лишний вызов memset. Я говорю об этом не для того, чтобы покритиковать эти компиляторы, – они оба очень хорошо оптимизируют код. Я просто хочу показать, что некоторые паттерны могут вызвать трудности даже у самых мощных компиляторов. До появления InitAll и связанных с ним оптимизаций Visual Studio не мог убрать лишний вызов.

Ещё более простой пример:

Между двумя вызовами memset находится всего один вызов функции без аргументов. Этот паттерн, как и предыдущий, очень часто встречается в коде Microsoft.

image loader

MSVC убирает лишний memset в этом примере. Clang 10.0.0 – тоже, а вот у GCC 9.3 по-прежнему не получается. Казалось бы, этот код можно легко оптимизировать, однако для этого компилятору приходится проводить нетривиальный анализ.

Проблема здесь (в MSVC) в том, что компилятор применяет анализ достижимости объекта, не зависящий от ветвления или потока исполнения. С точки зрения компилятора, переменная ‘s’ «убегает» из текущей функции (другими словами, её адрес передаётся куда-то за пределы этой функции), так как её адрес передаётся в функцию ‘DoStuff’. Компилятор также видит вызов memset ‘s’, затем – вызов ‘Dummy’, после чего – ещё один вызов memset ‘s’.

С точки зрения компилятора, поскольку переменная ‘s’ «убежала» из функции, функция ‘Dummy’ теоретически может считывать содержимое ‘s’ или изменять его до вызова функции ‘DoStuff’. А значит, вызов memset ни до, ни после ‘Dummy’ не может быть удалён.

Мы-то видим, что, хотя переменная ‘s’ и «убегает» из текущей функции, происходит это не раньше, чем вызывается функция ‘DoStuff’. Компилятор MSVC теперь тоже понимает это (в той или иной степени) и может убрать первый вызов memset.

Уменьшение размера memset

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

image loader

MSVC теперь может урезать размер первой memset, чтобы она инициализировала только те элементы в структуре, которые не инициализирует вторая memset. И снова GCC 9.3 и Clang 10.0.0 пока что не умеют проводить такую оптимизацию в этом примере.

Более эффективная развёртка memset

В следующем примере вызов memset нельзя убрать. Значит, его следует выполнить как можно эффективнее.

image loader

MSVC (как и большинство компиляторов) может «разворачивать» небольшие вызовы memset со статически определяемым размером и значением заполнения. То есть вызов memset заменяется последовательностью команд записи непосредственно в память. Благодаря этой оптимизации время выполнения небольших вызовов memset (до 128 байт) сокращается до одной четверти от обычного при меньшем объёме кода (нет необходимости сохранять значения регистров в стек, вызывать memset, а затем восстанавливать состояние регистров).

Раньше MSVC разворачивал memset на AMD64, используя регистры общего назначения. Теперь он использует векторные регистры, что позволяет разворачивать вызовы вдвое большего размера. В результате мы получаем более быстрые memset и не даём коду разрастаться.

Более производительные реализации memset

Этот пункт мы подробно разберём в другой раз.

Значение для пользователей

С тех пор как мы выпустили InitAll, многие из уязвимостей, о которых пользователи сообщали в MSRC, перестали воспроизводиться на свежих версиях Windows. Благодаря InitAll эти уязвимости из «проблем безопасности» превратились в «дефекты кода, на данный момент не имеющие негативных последствий». А значит, нам больше не нужно поставлять обновления безопасности для уже выпущенных операционных систем с установленным InitAll, что избавляет пользователей от головной боли, сопровождающей установку патчей, а Microsoft – от головной боли, сопровождающей их разработку.

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

Планы на будущее

На данный момент мы планируем заняться двумя главными задачами в контексте проблем с неинициализированными стековыми переменными:

Мы планируем опубликовать ещё одну заметку о текущей работе по нейтрализации уязвимостей, связанных с неинициализированной памятью, в механизме выделения пула памяти в ядре Windows.

Источник

Комфорт