Рабочим названием платформы .NET было |
Верификация CIL-кода. Библиотеки для создания метаинструментов
Особенности верификатора кода, используемого в .NET
Верификатор кода .NET относится ко второму типу. Он может доказать, что сборки, генерируемые компиляторами C#, J# и Visual Basic .NET, не разрушают память. При этом он терминируем и имеет линейную сложность.
Здесь может возникнуть следующий вопрос: зачем нужен верификатор, если компиляторы и так генерируют не разрушающий память код? Чтобы ответить на этот вопрос, нужно вспомнить, что:
- существуют компиляторы (например, Visual C++ with Managed Extensions), которые в общем случае могут порождать код, разрушающий память;
- в языке C# предусмотрены unsafe-блоки и unsafe-методы, внутри которых может находиться код, способный разрушить память;
- любой CIL-код, в том числе и разрушающий память, можно непосредственно компилировать с помощью ILASM;
- всеми этими возможностями может воспользоваться злоумышленник для написания вредоносного кода.
Другими словами, верификатор .NET предназначен не для поиска ошибок в программах, а для обеспечения безопасности системы.
Мы будем различать два достаточно близких понятия, относящихся к верификации. Это верифицированный код и верифицируемый код.
Верифицируемый код - это код, для которого верификатор может доказать, что он не разрушает память. Другими словами, для верифицируемого кода можно заранее, еще до запуска верификатора, сказать, что он успешно пройдет верификацию (такой код может генерироваться компилятором C#).
Верифицированный код - это код, для которого верификатор доказал, что он не разрушает память. То есть чтобы из верифицируемого кода получить верифицированный код, нужно обязательно запустить верификатор.
Алгоритм верификации
В спецификации CLI предложен базовый вариант алгоритма верификации. Любая реализация CLI должна включать верификатор, верифицирующий по крайней мере те программы, которые допускает базовый алгоритм.
Алгоритм верификации представляет собой вариант абстрактного интерпретатора CIL-кода. Линейность алгоритма верификации достигается за счет того, что он просматривает тело метода последовательно инструкция за инструкцией (при этом ни одна инструкция не обрабатывается дважды).
Совместимость типов
Пусть S и T - типы. Тогда S[] и T[] - соответствующие им массивные типы, а S& и T& - типы соответствующих управляемых указателей.
Тот факт, что S совместим по присваиванию с T, мы будем записывать как S := T.
Операция := рефлексивна и транзитивна.
- S := T, если S - базовый класс для T или интерфейс, реализуемый T, и при этом T не является типом-значением.
- S := T, если S и T - интерфейсы, и реализация T требует реализации S.
- S := null, если S - объектный тип или интерфейс.
- S[] := T[], если S := T и размерности массивов совпадают.
- S& := T&, если S := T.
Если ни одно из этих правил не выполняется, то типы S и T несовместимы.
Конфигурации стека
Будем называть конфигурацией стека данные о количестве слотов на стеке и типы значений, лежащих в этих слотах. При этом конфигурацию, содержащую 0 слотов, будем называть пустой конфигурацией.
Рассмотрим две операции над конфигурациями, которые используются в алгоритме верификации:
- проверка совместимости двух конфигураций;
- слияние двух конфигураций.
Операция проверки совместимости имеет два операнда: конфигурации C и K. Она возвращает булевское значение, показывающее, совместима ли конфигурация C с конфигурацией K.
Приведем алгоритм вычисления операции проверки совместимости:
- Если количество слотов в конфигурациях C и K различно, то возвращаем false. В противном случае пусть N - количество слотов в конфигурации C (естественно, и в K тоже).
- Если N = 0, то возвращаем true.
- Пусть i пробегает значения от 1 до N. Тогда для каждого такого i выполняем следующее:
- пусть S - тип i-го слота конфигурации C, а T - тип i-го слота конфигурации K ;
- если не T := S, то возвращаем false.
- Возвращаем true.
Операция слияния двух конфигураций также имеет два операнда: конфигурации C и K. Она может либо закончиться неуспехом, либо возвращает конфигурацию R, вычисляемую по следующему алгоритму:
- Если количество слотов в конфигурациях C и K различно, то алгоритм завершается неуспехом. В противном случае пусть N - количество слотов в конфигурации C (естественно, и в K тоже).
- Если N = 0, то возвращаем пустую конфигурацию.
- Пусть i пробегает значения от 1 до N. Тогда для каждого такого i выполняем следующее:
- пусть S - тип i-го слота конфигурации C, а T - тип i-го слота конфигурации K.
- вычисляем тип U i-го слота результирующей конфигурации:
- Возвращаем результирующую конфигурацию.
Описание алгоритма
В процессе работы алгоритма для каждой инструкции CIL вычисляется конфигурация стека. При этом фактические значения, лежащие на стеке, в локальных переменных и параметрах метода, не учитываются.
Алгоритм верификации работает со следующими данными:
- Неизменяемые данные:
- Изменяемые данные:
- Массив M размера N, в котором хранятся вычисляемые в процессе верификации конфигурации стека.
Возможны два результата работы алгоритма:
- Успешная верификация.
Метод не содержит неверифицируемых инструкций. Все возможные пути передачи управления в теле метода рассмотрены. Для каждой инструкции вычислена конфигурация стека.
- Неуспешная верификация.
Метод либо содержит неверифицируемые инструкции, либо в процессе вычисления конфигурации стека для некоторой инструкции было выявлено противоречие.
В начале работы алгоритма в массиве M не записано ни одной конфигурации.
Алгоритм последовательно просматривает массив P, то есть на каждом шаге работает с одной инструкцией P[i], где i пробегает значения от 1 до N (будем считать, что массивы P и M нумеруются, начиная с единицы).
На каждом шаге алгоритма выполняются следующие действия:
- Итак, P[i] - текущая рассматриваемая инструкция. Тогда смотрим, что собой представляет M[i]. Если M[i] еще не содержит конфигурацию стека, то записываем в M[i] пустую конфигурацию.
- Проверяем, верифицируема ли инструкция P[i], учитывая конфигурацию стека M[i] и информацию, содержащуюся в метаданных. Если инструкция в принципе не верифицируема или не может быть выполнена при заданной конфигурации стека, то алгоритм завершается неуспехом.
- Осуществляем абстрактное выполнение инструкции P[i]. Другими словами, вычисляем конфигурацию стека S, которая получилась бы после реального выполнения инструкции.
- Определяем, на какие инструкции может быть передано выполнение после инструкции P[i]. Для каждой такой инструкции P[j] выполняем следующее:
- если j < i, то проверяем, совместима ли конфигурация S с конфигурацией M[j]. Если оказывается, что несовместима, то алгоритм завершается неуспехом;
- если j >= i и M[j] еще не содержит конфигурации стека, то M[j] = S ;
- если j >= i и M[j] уже содержит конфигурацию стека, то выполняем попытку слияния конфигураций S и M[j]. Если попытка увенчалась успехом, то записываем результат слияния в M[j]. В противном случае алгоритм завершается неуспехом.
Если все инструкции были просмотрены и ни на одном шаге алгоритм не завершился неуспехом, то метод успешно прошел верификацию.