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-м и т.д.

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

© Habrahabr.ru