Вы наверняка интуитивно догадывались, что приложения, выполняемые на Intel x86 компьютерах, ограничены в своих возможностях, и что некоторые действия могут быть выполнены исключительно операционной системой. Но знаете ли вы, как это действительно работает? В данном посте рассмотрим уровни привилегий x86 — механизм, в котором ОС и процессор действуют сообща для того, чтобы ограничить то, что могут сделать user mode приложения.
Перевод статьи Gustavo Duarte: CPU Rings, Privilege, and Protection
Существуют четыре уровня привилегий, они пронумерованы от 0 (наиболее привилегированный уровень), до 3 (наименее привилегированный уровень), и три типа ресурсов, в отношении которых действуют механизмы защиты процессора: память, порты ввода / вывода и возможность выполнения некоторых инструкций. В любой момент, x86 процессор работает на определенном уровне привилегий, и от этого зависит, что может и чего не может сделать код. Уровни привилегий также часто называют кольцами защиты, которые изображаются в виде вложенных окружностей. Наиболее привилегированный уровень соответствует окружности с наибольшей степенью вложенности. Большинство современных ядер для архитектуры x86 используют всего два уровня привилегий — 0 и 3.
Выполнение порядка 15 инструкции (а всего их нескольких десятков) возможно только в кольце 0. Другие инструкции имеют ограничения, связанные с допустимыми операндами. Если бы не существовало этих ограничений, то невозможно бы было обеспечить функционирование механизмов защиты, т.к. упомянутные инструкции могут их обойти или привести к другим негативным последствиям. Инструкции, на которые существуют ограничения, могут использоватся только в коде ядра. Попытка их выполнения за пределами нулевого кольца, приведет к исключению #GP (general-protection exception). Точно такое же исключение происходит, например, когда программа пытается обратиться к невалидным адресам памяти. Аналогичным образом, в зависимости от уровня привилегий осуществляется ограничение доступа к памяти и портам ввода / вывода.
Перед тем как мы обратимся к рассмотрению механизма защиты, давайте посмотрим как именно процессор следит за текущим уровнем привилегий. К этому имеют непосредственное отношение сегментные селекторы (segment selectors), которые мы рассмотрели в предыдущем посте. Вот они:
Программист использует определенные инструкции для того, чтобы загрузить сегментный селектор для data сегмента в любой из сегментных регистров для данных (например SS или DS). При этом загружается все содержимое сегментного селектора, включая поле Requested Privilege Level (RPL), предназначение которого опишем немного ниже. Что касается CS регистра, то здесь все происходит несколько иначе. Во-первых, нельзя напрямую использовать load инструкции вроде mov для того, чтобы загрузить селектор в этот регистр. Вместо этого, содержимое регистра может измениться только как результат выполнения инструкции, которая управляет потоком выполнения, например far call. Во-вторых, и это для нас очень важно, вместо поля RPL, значение которого определяется программистом, селектор в CS регистре имеет поле Current Privilege Level (CPL), значение которого выставляется и контролируется самим процессором. Двухбитовое поле CPL в CS регистре всегда отражает текущий уровень привилегий, на котором работает процессор. Интелловские доки, в том числе те, которые опубликованы на просторах Интернета, могут иметь рассхождения по этому вопросу, однако, данный факт совершенно точно отражает суть вещей. В любой момент, чтобы там не происходило в самом процессоре, взглянув на значение поля CPL в CS регистре, мы всегда узнаем текущий уровень привилегий, с которым выполняется код.
Обратите внимание, текущий уровень привилегий процессора, не имеет ничего общего с привилегиями пользователями операционной системы. Совершенно не важно из под какой учетки вы работаете — root, Администратор, guest или обычный пользователь. Код всех пользовательских приложений выполняется в кольце 3, в то время как любой код, имеющий отношение к ядру, выполняется в кольце 0. Иногда некоторые типичные для ядра задачи могут выносить в user space – так, например, реализованы некоторые драйверы в Windows Vista, но это можно рассматривать всего лишь как частный случай, когда специального рода процессы просто выполняют некоторую работу для ядра. Обычно их можно убить без каких бы то ни было серьезных последствий.
Из-за существующих ограничений на доступ к памяти и портам ввода / вывода, выполняемая в user mode программа сама по себе фактически никак не может влиять на “окружающий мир” и ничего не может сделать без содействия ядра. Такая программа не может открыть файл, послать сетевой пакет, вывести на экран строчку текста, или выделить под себя память. Можно сказать, что пользовательские процессы выполняются в своего рода песочницах с кардинально урезанными возможностями, которые для них были уготованы “богами нулевого кольца”. Таким образом, утечка памяти, если таковая и вызвана процессом, не может пережить сам процесс, точно также как файлы, открытые во время жизнедеятельности процесса, не останутся открытыми после его завершения. Все структуры данных, используемые для контроля подобных вещей – выделенной памяти, открытых файлов – недоступны для пользовательского кода; как только программа завершает выполнение, её “песочница” уничтожается ядром. Именно поэтому современные сервера могут иметь по 600 часов аптайма – если железо или ядро не заглючит, все может проработать вечно. И это, кстати, причина, по которой Windows 95 / 98 так часто падала: нет, это не потому что “M$ — отстой”, просто для обеспечения обратной совместимости некоторые важные структуры данных оставили доступными для user mode приложений. В то время, это наверное был разумный компромисс, но обошелся он весьма дорогой ценой.
Процессор защищает память в двух стратегических точках: в момент, когда производится попытка загрузить сегментный селектор в регистр, а также когда происходит обращение к странице памяти. Механизм защиты, таким образом, отражает основные этапы адресной трансляции, где также задействованы и сегментация, и пейджинг. Попытка загрузить сегментный селектор сопровождается следующей проверкой:
Чем число больше, тем меньший уровень привилегий оно обозначает. Таким образом, функция MAX() выбирает значение, выражающее наименьший уровень привилегий (будь то CPL или RPL), которое затем сравнивается с уровнем привилегий целевого дескриптора (DPL). Если DPL больше или равен, до доступ разрешается. Весь смысл в использовании RPL в этой формуле заключается в том, что это позволяет ядру получать доступ к сегменту с умышленно заниженным уровнем привилегий, если это необходимо. Например, можно использовать селектор, в котором задан RPL равный 3 для того, ограничить некоторую операцию возможностью работать только с user mode сегментами данных. Проверка при загрузке селектора в SS регистр другая, здесь для успешного её прохождения все три значения CPL, RPL и DPL должны совпадать.
В действительности, защита на уровне сегментов особой роли не играет, так как современные ядра используют «плоскую» модель организации памяти, в которой user mode сегмент покрывает все доступное физическое адресное пространство. Сколь-нибудь полезная защита памяти осуществляется в paging unit при преобразовании линейного адреса в физический. Каждая страница памяти представляет собой последовательность байт, которая описывается записью в page table. В этой записи два поля имеют отношение к механизмам защиты, а именно supervisor флаг и read/write флаг. Supervisor флаг – основной механизм защиты, используемый современными ядрами. Когда данный флаг установлен, к странице нельзя обратиться из кольца 3. Хотя read/write флаг не играет никакой роли при проверке привилегий, он все равно находит интересное применение. При загрузке программы в память на выполнение, страницы памяти, хранящие исполняемый образ программы, помечаются как доступные только для чтения. Это позволяет отловить некоторые ошибки при работе с указателями, если с помощью них производится попытка осуществить запись в эти страницы. Read/write флаг также используется для реализации механизма copy on write при создании дочернего процесса с помощью системного вызова fork() в Unix-подобных ОС. Когда производиться форк, страницы памяти родительского процесса помечаются как доступные только для чтения. Дочерний процесс изначально будет использовать те же самые страницы памяти, что и родительский процесс. Если кто либо из них попытается осуществить запись в страницу памяти, процессор инициирует fault, и ядро отработает его следующим образом – оно создает для процесса, попытавшегося осуществить запись, собственный экземпляр страницы, а также выставит на эту страницу read / write права.
Идем дальше. Нам необходим механизм, который позволил бы CPU переключаться между разными уровнями привилегий. Если бы код, выполняющийся в кольце 3, мог передавать управление в произвольные места в ядре, можно было бы легко обойти защитные механизмы операционной системы просто осуществив jmp по неправильному (или правильному?) адресу. Чтобы воспрепятствовать этому, нужен механизм, обеспечивающий контролируемый трансфер потока выполнения. Он реализован на основе т.н. gate дескрипторов или инструкции sysenter. Gate дескриптор – это сегментный дискриптор, имеющий тип «system». Существуют четыре его разновидности: call-gate, interrupt-gate, trap-gate и task-gate дескрипторы. Посредством сall-gate дескриптора, ядро может предоставить точку входа, которую можно использовать с помощью обычных инструкций типа far call и far jump. Call-gate дескрипторы используются нечасто, поэтому рассказывать о них не будем. Task-gate дескрипторы нам то же мало интересны (в Linux они используются только при обработке double faults, которые обычно случаются из-за проблем с ядром или железом).
Остаются два других гораздо более интересных типа дескрипторов: interrupt-gate и trap-gate, которые используются для обработки хардверных прерываний (клавиатура, таймеры, диски) и исключений (page faults, деление на ноль). Оба эти типа механизмов я буду условно называть «прерывания». Данный тип дескрипторов хранится в таблице дескрипторов прерываний (IDT). Каждому прерыванию устанавливается в соответствие идентификационный номер от 0 до 255, называемый вектором. Когда нужно определить какой дескриптор использовать для обработки прерывания, процессором использует вектор в качестве указателя на дескриптор в IDT. Формат interrupt-gate и trap-gate дескрипторов фактически идентичен. Данный формат, а также проверка привилегий, которая совершается, когда происходит прерывание, изображены на рисунке. Я заполнил некоторые поля дескриптора теми значениями, которые обычно используются ядром Linux, чтобы добавить конкретики:
Доступ контролируется на основании текущего СPL и DPL целевого сегмента, а точка входа определяется на основе селектора и поля Offset в gate-дескрипторе. В современных ядрах, сегментный селектор, содержащийся в соответствующем поле gate-дескриптора, обычно выбирает code сегмент ядра. Механизм прерываний устроен так, что не может быть использован для передачи управления из более привилегированного кольца в менее привилегированное кольцо. Уровень привилегий должен либо остаться на прежнем уровне, либо повыситься (так происходит, когда, например, осуществляется прерывание user mode приложения). В любом случае, новое значение CPL будет равно DPL целевого code сегмента. В ситуации, когда CPL меняется, также происходит автоматическое переключение stack сегмента. Если прерывание является программным (вызвано выполнением инструкции INT n, например), дополнительно осуществляется еще одна проверка: gate DPL должен быть равен или больше чем исходный CPL. Данная проверка призвана сделать вызов некоторых обработчиков прерываний недоступным для пользовательского кода. В Linux, все обработчики прерываний выполняются в кольце 0.
Во время инициализации, функция ядра Linux setup_idt() создает IDT таблицу без указания конкретных точек входа в дескрипторах. Затем дескрипторы будут заполнены данными в соответствии с содержанием файлов include/asm-x86/desc.h и arch/x86/kernel/traps_32.c. В терминологии Linux, дескриптор, который имеет в своем названии слово «system», доступен для использования user mode кодом, и для него устанавливается gate DPL равный 3. “System gate” – это интелловский trap-gate, доступный для использования user mode кодом. Помимо этого, отличий в терминологии больше нет. Хардверные прерывания настраиваются не здесь, а в соответствующих драйверах.
Три гейта доступны для использования в user mode: векторы 3 и 4 используются для отладки и проверки на числовые переполнения, соответственно. Затем идёт system gate, имеюший ID равный значению константы SYSCALL_VECTOR — для x86 архитектуры это 0x80. Раньше это был основной механизм для передачи управления ядру при осуществлении системного вызова. Было время, когда и я хотел себе блатной номер с символами “int 0x80” . Начиная с Pentium Pro была добавлена новая инструкция sysenter, предназначенная для более быстрого осуществления системного вызова. Данная инструкция использует специальные регистры, которые хранят информацию о code сегменте, точку входа и т.п. При выполнении инструкции sysenter не производится проверка уровня привилегий, процессор сразу же переключается в CPL 0 и загружает соответствующие значения в регистры, имеющие отношения к коду и стеку (CS, EIP, SS и ESP). Загрузка значений в регистры, используемые инструкцией sysenter, возможна только из колца 0 и осуществляется функцией enable_sep_cpu().
Наконец, когда настает время вернуться в кольцо 3, ядро использует инструкцию IRET или SYSEXIT для того, чтобы вернуть контроль после обработки прерывания или системного вызова, соответственно. В результате, мы покидаем кольцо 0, и возобнавляется выполнение user mode кода с CPL равным 3. Vim подсказывает мне, что количество слов уже близко к отметке 1900, поэтому оставим тему портов ввода / вывода на потом. Всем спасибо за внимание!
Автор: Denis_msk