4 способа писать в защищённую страницу
Имеется в виду выполнение записи по аппаратно защищённому от записи адресу памяти в архитектуре x86. И то, как это делается в операционной системе Linux. И, естественно, в режиме ядра Linux, потому как в пользовательском пространстве, такие трюки запрещены. Бывает, знаете ли, непреодолимое желание записать в защищённую область … когда садишься писать вирус или троян…
Описание проблемы
… а если серьёзно, то проблема записи в защищённые от записи страницы оперативной памяти возникает время от времени при программировании модулей ядра под Linux. Например, при модификации селекторной таблицы системных вызовов sys_call_table для модификации, встраивания, имплементации, подмены, перехвата системного вызова — в разных публикациях это действие называют по разному. Но не только для этих целей… В очень кратком изложении ситуация выглядит так:
- В архитектуре x86 существует защитный механизм, который при попытке записи в защищённые от записи страницы памяти приводит к возбуждению исключения.
- Права доступа к странице (разрешение или запрет записи) описываются битом _PAGE_BIT_RW (1-й) в соответствующей этой странице структуре типа pte_t. Сброс этого бита запрещает запись в страницу.
- Со стороны процессора контролем защитой записи управляет бит X86_CR0_WP (16-й) системного управляющего регистра CR0 — при установленном этом бите попытка записи в защищённую от записи страницу возбуждает исключение этого процессора.
- Образец кода, испытанный и пригодный для использования;
- Известные мне ссылки на авторство подобного кода (хотя это очень относительно, потому как по решающим эту задачу способам существует достаточно много независимых источников);
Отключение страничной защиты, ассемблер
Простейшим решением данной проблемы является временное отключение страничной защиты сбросом бита X86_CR0_WP регистра CR0. Этот способ я использую добрый десяток лет, и он упоминается в нескольких публикациях разных лет, например, WP: Safe or Not? (Dan Rosenberg, 2011г.). Один из путей такой реализации — это инлайновые ассемблерные вставки (макросы, расширение компилятора GCC). В моём варианте и в демонстрационном тесте этот вариант выглядит так (файл rw_cr0.c):
(Сохранение и восстановление регистра eax можно исключить, здесь это показано … исключительно для чистоты эксперимента.)
Первое, что всегда возражают на такой метод по первому взгляду — это то, что, поскольку это основано на управлении конкретным процессором, в SMP системах между установкой регистра CR0 и записью в защищаемую область выполнение модуля может быть перепланировано на другой процессор, для которого страничная защита не отключена. Вероятность такого стечения обстоятельств не больше, чем если бы вас в центре Москвы укусила змея, сбежавшая из зоопарка. Но вероятность такого существует и она конечна, хоть и исчезающе мала. Для того, чтобы воспрепятствовать возникновению этой ситуации, из ассемблерного кода мы запрещаем локальные прерывания процессора операцией cli перед записью, и освобождаем прерывания только после завершения записи операцией sti (точно так же делает и Dan Rosenberg в упоминавшейся публикации).
Что намного неприятнее в показанном коде, это то, что он написан для 32-бит архитектуры (i386), а в 64-бит архитектуре не будет не только выполняться, но даже компилироваться. Разрешить это можно тем, что иметь различные коды, зависящие от архитектуры:
Отключение страничной защиты, API ядра
Можно сделать то же самое, что и ранее, но опираясь не ассемблерный код, а на API ядра (файл rw_pax.c). Вот фрагмент такого кода почти в том же неизменном виде, как его приводит Dan Rosenberg:
Примечание «почти» относится к тому, что вызов preempt_enable_no_resched() был доступен до ядра 3.13 (в 2011г. когда писалась статья). Начиная с ядра 3.14 и далее этот вызов закрыт вот таким условным препроцессорным определением:
Но макросы preempt_enable_no_resched() и preempt_count_dec() определены в более поздних ядрах практически идентично.
Куда неприятнее то обстоятельство, что показанный код благополучно выполняется и в поздних версиях (старше 3.14) ядра, но вскоре после его выполнения, из других приложений появляются предупреждающие (warning) сообщения ядра, вида:
(Я не вникал детально в происходящее… не считал нужным, но это как-то связано с нарушением балансировки работ между процессорами SMP, или оценкой такой балансировки.)
Возникающие в ядре даже предупреждения — это уже достаточно серьёзно, от них хотелось бы избавиться. Этого можно достигнуть повторив трюк с локальными прерываниями из ранее рассмотренного ассемблерного кода (файл rw_pai.c):
Этот код успешно и компилируется и работает в архитектурах и 32 и 64 бит и в этом его достоинство перед предыдущим.
Снятие защиты со страницы памяти
Следующий предложенный способ — установка бита _PAGE_BIT_RW в PTE-записи, описывающей интересующую нас страницу памяти (файл rw_pte.c):
По логике выполнения код абсолютно понятен. Сам код в виде почти том, как он здесь показан, я впервые встречал в обсуждении на Хабрахабр (Alexey Derlaft, г.Владимир, 2013г.), а позже, гораздо обстоятельнее, в обсуждении на форуме модификация системных вызовов (Max Filippov, г.Санкт-Петербург, 2015г.). Этот код проверен и в 32 и в 64 бит архитектуре.
Наложение отображения участка памяти
Ещё один способ (последний из рассматриваемых на сегодня) предложен в статье «Кошерный способ модификации защищённых от записи областей ядра Linux» (Ilya V. Matveychikov, г.Москва, конец 2013г.). Я не скажу ничего ни хорошего ни плохого о кулинарных пристрастиях автора его национальной кухне… не в курсе, но в отношении предложенного технического приёма должен отметить, что он оригинален и красив (файл rw_map.c):
Этот способ работает и в 32 и в 64 бит архитектуре. В некоторый минус его можно отнести некоторую громоздкость для решения достаточно простой задачи («из пушки по воробьям»), при том, что, на первый взгляд, в нём не видно существенных преимуществ перед предыдущими способами. Но эта техника (и практически в неизменном виде этот код) может быть с успехом использована для более широкого круга задач, чем обсуждаемая.
Тест выполнения
А теперь, чтобы не быть голословным, пришло время проверить всё выше сказанное натурным экспериментом. Для проверки создадим модуль ядра (файл srw.c):
- В едином коде нужно было согласовать различные прототипы функций разрешающих запись, принадлежащих разным способам (по действию они одинаковы, но вызываются по-разному).
- Реализация для разных способов сохранялась максимально приближенной тому, как она записана у разных авторов (изменения вносились только для соответствия синтаксиса более свежим версиям ядрам). Этим и объясняется разнообразие прототипов функций.
И вот как это выглядит только в одной из тестируемых архитектур (реально тестировалось не менее 5-ти различных архитектур и версий ядра) поочерёдное использование всех способов:
Обсуждение
Данный обзор составлен не в качестве учебника или руководства к действию. Здесь только систематически собраны разные приёмы с эквивалентными, по существу, действиями, используемые разными авторами.
Интересно было бы продолжить обсуждение относительно преимуществ и недостатков каждого из перечисленных способов.
Или дополнить перечисленные способы выполнить действие новыми вариантами… 5-м, 6-м и т.д.
Все обсуждавшиеся коды (для проверки, или использования, или дальнейшего улучшения) могут быть взяты здесь или здесь.