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):
static inline void rw_enable( void ) {
asm( "cli \n"
"pushl %eax \n"
"movl %cr0, %eax \n"
"andl $0xfffeffff, %eax \n"
"movl %eax, %cr0 \n"
"popl %eax" );
}
static inline void rw_disable( void ) {
asm( "pushl %eax \n"
"movl %cr0, %eax \n"
"orl $0x00010000, %eax \n"
"movl %eax, %cr0 \n"
"popl %eax \n"
"sti " );
}
(Сохранение и восстановление регистра eax можно исключить, здесь это показано … исключительно для чистоты эксперимента.)
Первое, что всегда возражают на такой метод по первому взгляду — это то, что, поскольку это основано на управлении конкретным процессором, в SMP системах между установкой регистра CR0 и записью в защищаемую область выполнение модуля может быть перепланировано на другой процессор, для которого страничная защита не отключена. Вероятность такого стечения обстоятельств не больше, чем если бы вас в центре Москвы укусила змея, сбежавшая из зоопарка. Но вероятность такого существует и она конечна, хоть и исчезающе мала. Для того, чтобы воспрепятствовать возникновению этой ситуации, из ассемблерного кода мы запрещаем локальные прерывания процессора операцией cli перед записью, и освобождаем прерывания только после завершения записи операцией sti (точно так же делает и Dan Rosenberg в упоминавшейся публикации).
Что намного неприятнее в показанном коде, это то, что он написан для 32-бит архитектуры (i386), а в 64-бит архитектуре не будет не только выполняться, но даже компилироваться. Разрешить это можно тем, что иметь различные коды, зависящие от архитектуры:
#ifdef __i386__
// ... то, что было показано выше
#else
static inline void rw_enable( void ) {
asm( "cli \n"
"pushq %rax \n"
"movq %cr0, %rax \n"
"andq $0xfffffffffffeffff, %rax \n"
"movq %rax, %cr0 \n"
"popq %rax " );
}
static inline void rw_disable( void ) {
asm( "pushq %rax \n"
"movq %cr0, %rax \n"
"xorq $0x0000000000001000, %rax \n"
"movq %rax, %cr0 \n"
"popq %rax \n"
"sti " );
}
#endif
Можно сделать то же самое, что и ранее, но опираясь не ассемблерный код, а на API ядра (файл rw_pax.c). Вот фрагмент такого кода почти в том же неизменном виде, как его приводит Dan Rosenberg:
#include <linux/preempt.h>
#include <asm/paravirt.h>
#include <asm-generic/bug.h>
#include <linux/version.h>
static inline unsigned long native_pax_open_kernel( void ) {
unsigned long cr0;
preempt_disable();
barrier();
cr0 = read_cr0() ^ X86_CR0_WP;
BUG_ON( unlikely( cr0 & X86_CR0_WP ) );
write_cr0( cr0 );
return cr0 ^ X86_CR0_WP;
}
static inline unsigned long native_pax_close_kernel( void ) {
unsigned long cr0;
cr0 = read_cr0() ^ X86_CR0_WP;
BUG_ON( unlikely( !( cr0 & X86_CR0_WP ) ) );
write_cr0( cr0 );
barrier();
#if LINUX_VERSION_CODE < KERNEL_VERSION(3,14,0)
preempt_enable_no_resched();
#else
preempt_count_dec();
#endif
return cr0 ^ X86_CR0_WP;
}
Примечание «почти» относится к тому, что вызов preempt_enable_no_resched() был доступен до ядра 3.13 (в 2011г. когда писалась статья). Начиная с ядра 3.14 и далее этот вызов закрыт вот таким условным препроцессорным определением:
#ifdef MODULE
/*
* Modules have no business playing preemption tricks.
*/
#undef sched_preempt_enable_no_resched
#undef preempt_enable_no_resched
Но макросы preempt_enable_no_resched() и preempt_count_dec() определены в более поздних ядрах практически идентично.
Куда неприятнее то обстоятельство, что показанный код благополучно выполняется и в поздних версиях (старше 3.14) ядра, но вскоре после его выполнения, из других приложений появляются предупреждающие (warning) сообщения ядра, вида:
[ 337.230937] ------------[ cut here ]------------
[ 337.230949] WARNING: CPU: 1 PID: 3410 at /build/buildd/linux-lts-utopic-3.16.0/init/main.c:802 do_one_initcall+0x1cb/0x1f0()
[ 337.230955] initcall rw_init+0x0/0x1000 [srw] returned with preemption imbalance
(Я не вникал детально в происходящее… не считал нужным, но это как-то связано с нарушением балансировки работ между процессорами SMP, или оценкой такой балансировки.)
Возникающие в ядре даже предупреждения — это уже достаточно серьёзно, от них хотелось бы избавиться. Этого можно достигнуть повторив трюк с локальными прерываниями из ранее рассмотренного ассемблерного кода (файл rw_pai.c):
static inline unsigned long native_pai_open_kernel( void ) {
unsigned long cr0;
local_irq_disable();
barrier();
cr0 = read_cr0() ^ X86_CR0_WP;
BUG_ON( unlikely( cr0 & X86_CR0_WP ) );
write_cr0( cr0 );
return cr0 ^ X86_CR0_WP;
}
static inline unsigned long native_pai_close_kernel( void ) {
unsigned long cr0;
cr0 = read_cr0() ^ X86_CR0_WP;
BUG_ON( unlikely( !( cr0 & X86_CR0_WP ) ) );
write_cr0( cr0 );
barrier();
local_irq_enable();
return cr0 ^ X86_CR0_WP;
}
Этот код успешно и компилируется и работает в архитектурах и 32 и 64 бит и в этом его достоинство перед предыдущим.
Следующий предложенный способ — установка бита _PAGE_BIT_RW в PTE-записи, описывающей интересующую нас страницу памяти (файл rw_pte.c):
#include <asm/pgtable_types.h>
#include <asm/tlbflush.h>
static inline void mem_setrw( void **table ) {
unsigned int l;
pte_t *pte = lookup_address( (long unsigned int)table, &l );
pte->pte |= _PAGE_RW;
__flush_tlb_one( (unsigned long)table );
}
static inline void mem_setro( void **table ) {
unsigned int l;
pte_t *pte = lookup_address( (long unsigned int)table, &l );
pte->pte &= ~_PAGE_RW;
__flush_tlb_one( (unsigned long)table );
}
По логике выполнения код абсолютно понятен. Сам код в виде почти том, как он здесь показан, я впервые встречал в обсуждении на Хабрахабр (Alexey Derlaft, г.Владимир, 2013г.), а позже, гораздо обстоятельнее, в обсуждении на форуме модификация системных вызовов (Max Filippov, г.Санкт-Петербург, 2015г.).
Этот код проверен и в 32 и в 64 бит архитектуре.
Ещё один способ (последний из рассматриваемых на сегодня) предложен в статье «Кошерный способ модификации защищённых от записи областей ядра Linux» (Ilya V. Matveychikov, г.Москва, конец 2013г.). Я не скажу ничего ни хорошего ни плохого о кулинарных пристрастиях автора его национальной кухне… не в курсе, но в отношении предложенного технического приёма должен отметить, что он оригинален и красив (файл rw_map.c):
static void *map_writable( void *addr, size_t len ) {
void *vaddr;
int nr_pages = DIV_ROUND_UP( offset_in_page( addr ) + len, PAGE_SIZE );
struct page **pages = kmalloc( nr_pages * sizeof(*pages), GFP_KERNEL );
void *page_addr = (void*)( (unsigned long)addr & PAGE_MASK );
int i;
if( pages == NULL )
return NULL;
for( i = 0; i < nr_pages; i++ ) {
if( __module_address( (unsigned long)page_addr ) == NULL ) {
pages[ i ] = virt_to_page( page_addr );
WARN_ON( !PageReserved( pages[ i ] ) );
} else {
pages[i] = vmalloc_to_page(page_addr);
}
if( pages[ i ] == NULL ) {
kfree( pages );
return NULL;
}
page_addr += PAGE_SIZE;
}
vaddr = vmap( pages, nr_pages, VM_MAP, PAGE_KERNEL );
kfree( pages );
if( vaddr == NULL )
return NULL;
return vaddr + offset_in_page( addr );
}
static void unmap_writable( void *addr ) {
void *page_addr = (void*)( (unsigned long)addr & PAGE_MASK );
vfree( page_addr );
}
Этот способ работает и в 32 и в 64 бит архитектуре. В некоторый минус его можно отнести некоторую громоздкость для решения достаточно простой задачи («из пушки по воробьям»), при том, что, на первый взгляд, в нём не видно существенных преимуществ перед предыдущими способами. Но эта техника (и практически в неизменном виде этот код) может быть с успехом использована для более широкого круга задач, чем обсуждаемая.
А теперь, чтобы не быть голословным, пришло время проверить всё выше сказанное натурным экспериментом. Для проверки создадим модуль ядра (файл srw.c):
#include "rw_cr0.c"
#include "rw_pte.c"
#include "rw_pax.c"
#include "rw_map.c"
#include "rw_pai.c"
#define PREFIX "! "
#define LOG(...) printk( KERN_INFO PREFIX __VA_ARGS__ )
#define ERR(...) printk( KERN_ERR PREFIX __VA_ARGS__ )
#define __NR_rw_test 31 // неиспользуемая позиция sys_call_table
static int mode = 0;
module_param( mode, uint, 0 );
#define do_write( addr, val ) { \
LOG( "writing address %p\n", addr ); \
*addr = val; \
}
static bool write( void** addr, void* val ) {
switch( mode ) {
case 0:
rw_enable();
do_write( addr, val );
rw_disable();
return true;
case 1:
native_pax_open_kernel();
do_write( addr, val );
native_pax_close_kernel();
return true;
case 2:
mem_setrw( addr );
do_write( addr, val );
mem_setro( addr );
return true;
case 3:
addr = map_writable( (void*)addr, sizeof( val ) );
if( NULL == addr ) {
ERR( "wrong mapping\n" );
return false;
}
do_write( addr, val );
unmap_writable( addr );
return true;
case 4:
native_pai_open_kernel();
do_write( addr, val );
native_pai_close_kernel();
return true;
default:
ERR( "illegal mode %d\n", mode );
return false;
}
}
static int __init rw_init( void ) {
void **taddr; // адрес sys_call_table
asmlinkage long (*sys_ni_syscall) ( void ); // оригинальный вызов __NR_rw_test
if( NULL == ( taddr = (void**)kallsyms_lookup_name( "sys_call_table" ) ) ) {
ERR( "sys_call_table not found\n" ); return -EFAULT;
}
LOG( "sys_call_table address = %p\n", taddr );
sys_ni_syscall = (void*)taddr[ __NR_rw_test ]; // сохранить оригинал
if( !write( taddr + __NR_rw_test, (void*)0x12345 ) ) return -EINVAL;
LOG( "modified sys_call_table[%d] = %p\n", __NR_rw_test, taddr[ __NR_rw_test ] );
if( !write( taddr + __NR_rw_test, (void*)sys_ni_syscall ) ) return -EINVAL;
LOG( "restored sys_call_table[%d] = %p\n", __NR_rw_test, taddr[ __NR_rw_test ] );
return -EPERM;
}
module_init( rw_init );
Некоторая тяжеловесность, громоздкость кода обусловлена только тем, что:
- В едином коде нужно было согласовать различные прототипы функций разрешающих запись, принадлежащих разным способам (по действию они одинаковы, но вызываются по-разному).
- Реализация для разных способов сохранялась максимально приближенной тому, как она записана у разных авторов (изменения вносились только для соответствия синтаксиса более свежим версиям ядрам). Этим и объясняется разнообразие прототипов функций.
И вот как это выглядит только в одной из тестируемых архитектур (реально тестировалось не менее 5-ти различных архитектур и версий ядра) поочерёдное использование всех способов:
$ uname -r
3.16.0-48-generic
$ uname -m
x86_64
$ sudo insmod srw.ko mode=0
insmod: ERROR: could not insert module srw.ko: Operation not permitted
$ dmesg | tail -n6
[ 7258.575977] ! detected 64-bit platform
[ 7258.584504] ! sys_call_table address = ffffffff81801460
[ 7258.584579] ! writing address ffffffff81801558
[ 7258.584653] ! modified sys_call_table[31] = 0000000000012345
[ 7258.584654] ! writing address ffffffff81801558
[ 7258.584666] ! restored sys_call_table[31] = ffffffff812db550
$ sudo insmod srw.ko mode=2
insmod: ERROR: could not insert module srw.ko: Operation not permitted
$ dmesg | tail -n6
[ 7282.625539] ! detected 64-bit platform
[ 7282.633020] ! sys_call_table address = ffffffff81801460
[ 7282.633129] ! writing address ffffffff81801558
[ 7282.633178] ! modified sys_call_table[31] = 0000000000012345
[ 7282.633228] ! writing address ffffffff81801558
[ 7282.633291] ! restored sys_call_table[31] = ffffffff812db550
$ sudo insmod srw.ko mode=3
insmod: ERROR: could not insert module srw.ko: Operation not permitted
$ dmesg | tail -n6
[ 7297.040272] ! detected 64-bit platform
[ 7297.059764] ! sys_call_table address = ffffffff81801460
[ 7297.065930] ! writing address ffffc900001e6558
[ 7297.066000] ! modified sys_call_table[31] = 0000000000012345
[ 7297.066035] ! writing address ffffc9000033d558
[ 7297.066073] ! restored sys_call_table[31] = ffffffff812db550
$ sudo insmod srw.ko mode=4
insmod: ERROR: could not insert module srw.ko: Operation not permitted
$ dmesg | tail -n6
[ 7309.831119] ! detected 64-bit platform
[ 7309.836299] ! sys_call_table address = ffffffff81801460
[ 7309.836311] ! writing address ffffffff81801558
[ 7309.836359] ! modified sys_call_table[31] = 0000000000012345
[ 7309.836368] ! writing address ffffffff81801558
[ 7309.836424] ! restored sys_call_table[31] = ffffffff812db550
Данный обзор составлен не в качестве учебника или руководства к действию. Здесь только систематически собраны разные приёмы с эквивалентными, по существу, действиями, используемые разными авторами.
Интересно было бы продолжить обсуждение относительно преимуществ и недостатков каждого из перечисленных способов.
Или дополнить перечисленные способы выполнить действие новыми вариантами… 5-м, 6-м и т.д.
Все обсуждавшиеся коды (для проверки, или использования, или дальнейшего улучшения) могут быть взяты здесь или здесь.