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 ядра


    Можно сделать то же самое, что и ранее, но опираясь не ассемблерный код, а на 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-м и т.д.

    Все обсуждавшиеся коды (для проверки, или использования, или дальнейшего улучшения) могут быть взяты здесь или здесь.
    ua-hosting.company
    Хостинг-провайдер: серверы в NL до 300 Гбит/с

    Comments 9

      +1
      Отличная статья, спасибо!
      Всегда хотел понять различия между способами)
        +3
        Все обсуждавшиеся коды (для проверки, или использования, или дальнейшего улучшения) могут быть взяты здесь или здесь.
        yadi.sk
        drive.google.com
        Может, лучше GitHub, не?
          +1
          может и лучше ;-)
          но я специально не размещал такие мизерные архивы на GitHub.
            +5
            Есть такой вариант: gist.github.com
              +4
              я проверю ;-)
              если читателям так удобнее — добавлю и туда.
                +1
                Добавьте, если не сложно.
          0
          #if LINUX_VERSION_CODE < KERNEL_VERSION(3,14,0) 
              preempt_enable_no_resched(); 
          #else 
              preempt_count_dec(); 
          #endif 
          


          preempt_enable_no_resched() был доступен до ядра 3.13

          А зачем здесь вообще preempt_enable_no_resched? Чем плох preempt_enable?

          static inline unsigned long native_pai_open_kernel( void ) { 
             ...
             local_irq_disable(); 
             ...
          } 
          
          static inline unsigned long native_pai_close_kernel( void ) { 
             ...
             local_irq_enable();
             ...
          }
          

          Правильнее было бы local_irq_save… local_irq_restore.
            0
            А зачем здесь вообще preempt_enable_no_resched? Чем плох preempt_enable?

            1. я написал, может, значит, недостаточно внятно, что я постарался максимально неприкосновенно сохранить код, так как он написан был в первоисточнике… у автора там preempt_enable_no_resched()
            2.… но preempt_enable() столь же плох — в ядре >3.14 он порождает warning показанный в тексте
              0
              честный способ — только наложение. остальные чреваты races condition.

              Only users with full accounts can post comments. Log in, please.