Сетевые системные вызовы. Часть 3

    Предыдущую часть обсуждения мы завершили на такой вот оптимистической ноте: «Подобным образом мы можем изменить поведение любого системного вызова Linux». И тут я слукавил — любого… да не любого. Исключение составляют (могут составлять) группа сетевых системных вызовов, работающих с BSD сокетами. Когда сталкиваешься с этим артефактом в первый раз — это изрядно озадачивает.

    Как происходит сокетный вызов


    Для прояснения картины воспользуемся заметками одного из непосредственных разработчиков сетевой подсистемы Linux:
    Network systems calls on Linux (2008 год). Я коротко перескажу её основное содержание (в интересующей нас части), кому это не интересно может воспользоваться оригиналом.

    Когда поддержка BSD сокетов были добавлена в ядро Linux, разработчики решили добавить их единовременно все 17 (на сегодня 20) сокетных вызовов, и добавили для этих вызовов один дополнительный уровень косвенности. Для всей группы этих вызовов введен один новый, редко упоминаемый, системный вызов (см. man socketcall(2)):
    int socketcall( int call, unsigned long *args ); 
    

    где:
    — call — численный номер сетевого вызова (SYS_CONNECT, SYS_ACCEPT… мы их увидим вскоре);
    — args — указатель 6-ти элементного массива (блок параметров), в который последовательно упакованы все параметры любого из системных вызовов этой группы (сетевой), без различения их типа (приведенные к unsigned long);

    А вот такой макрос в ядре (<net/socket.c>), в котором «зашито» сколько фактически параметров должен использовать каждый из сокетных вызовов в зависимости от его номера (в диапазоне от 1 до 20):
    /* Argument list sizes for sys_socketcall */ 
    #define AL(x) ((x) * sizeof(unsigned long)) 
    static const unsigned char nargs[ 21 ] = { 
           AL(0),AL(3),AL(3),AL(3),AL(2),AL(3), 
           AL(3),AL(3),AL(4),AL(4),AL(4),AL(6), 
           AL(6),AL(2),AL(5),AL(5),AL(3),AL(3),
           AL(4),AL(5),AL(4)
    }; 
    #undef AL
    

    (Причём, narg[ 0 ] вообще не используется, потому размерность его и 21.)

    Номер сокетного вызова в пространство ядра (int 0x80 или sysenter) передаётся в регистре eax. Значения самих этих констант мы можем подсмотреть в заголовках пространства пользователя (<linux/net.h>):
    #define SYS_SOCKET      1               /* sys_socket(2)                */ 
    #define SYS_BIND        2               /* sys_bind(2)                  */ 
    #define SYS_CONNECT     3               /* sys_connect(2)               */ 
    #define SYS_LISTEN      4               /* sys_listen(2)                */ 
    #define SYS_ACCEPT      5               /* sys_accept(2)                */ 
    ...
    #define SYS_SENDMSG     16              /* sys_sendmsg(2)               */ 
    #define SYS_RECVMSG     17              /* sys_recvmsg(2)               */ 
    #define SYS_ACCEPT4     18              /* sys_accept4(2)               */ 
    #define SYS_RECVMMSG    19              /* sys_recvmmsg(2)              */ 
    #define SYS_SENDMMSG    20              /* sys_sendmmsg(2)              */ 
    

    Собственно, схема обработки к этому моменту уже должна быть понятна:
    — необходимое число параметров системного вызова пакуется в массив unsigned long, наибольшее число параметров (6) для SYS_SENDTO=11 (nargs[ 11 ]):
    ssize_t sendto( int sockfd, const void *buf, size_t len, int flags, 
                    const struct sockaddr *dest_addr, socklen_t addrlen ); 
    

    — адрес сформированного массива передаётся 2-м параметром системного вызова, первым параметром передаётся номер сокетного вызова (например SYS_SENDTO);
    — все сокетные вызовы обрабатываются единственным обработчиком ядра sys_socketcall() (__NR_socketcall = 102);
    — обработчик сначала копирует из пространства пользователя массив значений-параметров, а далее, в зависимости от eax, копирует из пространства пользователя вослед и области данных, указываемые (возможно) значениями указателей из этого массива параметров.

    Некоторые новые архитектуры (так в оригинале) не используют такой непрямой способ вызова, а используют для этих вызовов такую же реализацию, как и для всех остальных системных вызовов. Так это реализовано, в частности, для X86_64 и ARM. Таким образом, даже 64-битовые и 32-битовые (эмулируемые в системе X86_64) приложения будут выполняться по разной схеме. Но не станем на это пока отвлекаться…

    Удостовериться в том, что обслуживание сокетных вызовов в 32 и 64 битовых системах осуществляется принципиально по-разному, можно если в каталоге приложений пространства пользователя (заголовочные файлы библиотек языка C, <i386-linux-gnu/asm>) рассмотреть, для сравнения, определения набора системных вызовов для 32 и 64 битовых режимов:
    $ cat unistd_32.h | grep socketcall 
    #define __NR_socketcall 102 
    $ cat unistd_32.h | grep connect 
    

    $ cat unistd_64.h | grep socketcall 
    $ cat unistd_64.h | grep connect 
    #define __NR_connect 42        
    

    В 32-бит системе присутствует вызов sys_socketcall(), но отсутствуют вызовы для каждого из 20 сокетых вызовов. И напротив, в 64-бит системе отсутствует такой системный вызов как sys_socketcall(), но присутствует весь полный набор системных вызовов для каждого из 20-ти сокетных вызовов.

    Сам же автор заметки в завершение, в качестве оценки, пишет следующее: Данная методика кажется довольно уродливой (rather ugly) на первый взгляд, при сравнении с современными методами объектно-ориентированного программирования, но есть и определенная простота в нем. Он, также, хранит данные компактно, что улучшает попадание в кэши. Единственная проблема заключается в том, что выборка должна быть выполнена вручную, а это означает, что здесь легко выстрелить себе в ногу.

    Реализация


    Возможность перехвата сетевых системных вызовов будем иллюстрировать на макете распределённого файервола (максимально его упростив). Одно время с этой идеей очень сильно носились, в качестве реализации файервола для больших и сверхбольших сетей (особенно в окружении Cisco). Существует много публикаций на эту тему, например, две из них, дающие полное представление о том, что понимается как распределённый файервол: Implementing a Distributed Firewall и
    Automated Implementation of Stateful Firewalls in Linux.

    Предложение состоит в том, чтобы контролировать не весь TCP/IP трафик на уровне IP пакетов, а осуществлять регламент на каждом хосте сверхбольшой сети только для протокола TCP и только в момент установления соединения. Под контроль попадают только 2 системных вызова: accept() и connect(). Более глубокое обсуждение распределённого файервола увело бы нас очень далеко от наших целей … рассмотрим только то как мы могли бы контролировать эти сетевые сетевые вызовы.

    В качестве иллюстрации реализации перехвата сокетных вызовов был реализован модуль такого сетевого фильтра я ядре для вызовов accept() и connect(). Сделан этот модуль в максимально упрощенной (усечённой) реализации: в качестве параметров при загрузке модуль получает IP адрес (параметр deny) и TCP порт (параметр port), соединения с которыми должны быть запрещены (и ещё один дополнительный параметр debug — уровень диагностического вывода).

    Примечание: В тестируемом варианте запрещённые IP адреса и TCP порты допускались множественными, хранились в циклическом списке типа struct list_head (как это и принято повсеместно в ядре), а помещались (или удалялись) они туда отдельным приложением — демоном политики в пространстве пользователя. Фильтр в ядре и должен функционировать некоторым подобным образом, но это слишком громоздко для статьи, описывающей принцип, тем более, что не принцип файервола, а принцип работы с сетевыми системными вызовами. При всех упрощениях код всё ещё великоват, поэтому я помеаю его под спойлер.

    Итак, код модуля-примера:
    static int debug = 0;                                       // debug output level: 0, 1, 2 
    module_param( debug, uint, 0 ); 
    static char* deny;                                          // string parameter: denied IPv4 
    module_param( deny, charp, 0 ); 
    static int port = 0;                                        // denied port 
    module_param( port, int, 0 ); 
    
    static void **taddr;                                        // table sys_call_table address 
    u32 ipdeny;                                                 // denied IP 
    
    #include "find.c" 
    #include "CR0.c" 
    
    inline char* in4_ntoa( uint32_t ip ) {                      // mapping IP to a string 
       static char saddr[ MAX_ADDR_LEN ]; 
       sprintf( saddr, "%d.%d.%d.%d", 
                ( ip >> 24 ) & 0xFF, ( ip >> 16 ) & 0xFF, 
                ( ip >> 8 ) & 0xFF, ( ip ) & 0xFF 
              ); 
       return saddr; 
    } 
    
    asmlinkage long (*old_sys_socketcall) ( int call, unsigned long __user *args ); 
    
    asmlinkage long new_sys_socketcall( int call, unsigned long __user *args ) { 
    #define PARMS 3 
       static unsigned long a[ PARMS ]; // accept() and connect() have the same number of parameters 3 
       static struct sockaddr sa; 
       // ----------- nested functions are a GCC extension --------- 
       long get_addr( void ) { 
          const unsigned int len = PARMS * sizeof( unsigned long ); 
          if( copy_from_user( a, args, len ) ) 
             return -EFAULT; 
          if( copy_from_user( &sa, (struct sockaddr __user*)a[ 1 ], sizeof( struct sockaddr ) ) ) 
             return -EFAULT; 
          return 0; 
       } 
       // ---------------------------------------------------------- 
       long ret; 
       if( SYS_ACCEPT == call ) {                               // accept() before syscall 
          long err; 
          if( ( err = get_addr() ) < 0 ) return err; 
          if( AF_INET == sa.sa_family ) {                       // only IPv4 
             struct sockaddr_in *usin = (struct sockaddr_in *)&sa; 
             if( ntohs( usin->sin_port ) == port ) { 
                LOG( "accept from denied port %d\n", ntohs( usin->sin_port ) ); 
                return -EIO; 
             } 
          } 
       } 
       if( SYS_CONNECT == call ) {                       // connect() before syscall 
          long err; 
          if( ( err = get_addr() ) < 0 ) return err; 
          if( AF_INET == sa.sa_family ) {                // only IPv4 
             struct sockaddr_in *usin = (struct sockaddr_in *)&sa; 
             DEB( "connect to %s:%d\n", 
                  in4_ntoa( ntohl( usin->sin_addr.s_addr ) ), ntohs( usin->sin_port ) ); 
             if( ( deny != NULL && ntohl( usin->sin_addr.s_addr ) == ipdeny ) || 
                 ( port  != 0 && ntohs( usin->sin_port ) == port ) )  { 
                LOG( "connect to %s:%d denied\n", 
                     in4_ntoa( ntohl( usin->sin_addr.s_addr ) ), ntohs( usin->sin_port ) ); 
                return -EACCES; 
             } 
          } 
       } 
       ret = old_sys_socketcall( call, args );           // retranslate to original sys_socketcall() 
       if( SYS_ACCEPT == call ) {                        // accepr() after syscall 
          long err; 
          if( ( err = get_addr() ) < 0 ) return err; 
          if( AF_INET == sa.sa_family ) {                // only IPv4 
             struct sockaddr_in *usin = (struct sockaddr_in *)&sa; 
             DEB( "accept from %s:%d\n", 
                  in4_ntoa( ntohl( usin->sin_addr.s_addr ) ), ntohs( usin->sin_port ) ); 
             if( ( deny != NULL && ntohl( usin->sin_addr.s_addr ) == ipdeny ) || 
                 ( port  != 0 && ntohs( usin->sin_port ) == port ) )  { 
                LOG( "accept from %s:%d denied\n", 
                     in4_ntoa( ntohl( usin->sin_addr.s_addr ) ), ntohs( usin->sin_port ) ); 
                return -EACCES; 
             } 
          } 
       } 
       return ret; 
    } 
    
    static int __init init( void ) { 
       void *waddr; 
       // ----------- nested functions are a GCC extension --------- 
       int pos_in_table( const char *symbol ) {          // position in sys_call_table (__NR_*) 
          const int last = __NR_process_vm_writev;       // near last syscall in i386 
          int n; 
          waddr = find_sym( symbol ); 
          if( NULL == waddr ) return -1; 
          for( n = 0; n <= last; n++ ) 
             if( taddr[ n ] == waddr ) break; 
          return n <= last ? n : -1; 
       } 
       // -------------------------------------------------------- 
       void show_in_table( char *symb ) {                // print info about symbol 
          waddr = find_sym( symb ); 
          if( NULL == waddr ) { 
             DEB( "symbol %s not found in kernel\n", symb ); 
          } 
          else { 
             int n = pos_in_table( symb ); 
             if( n > 0 ) 
                DEB( "symbol %s address = %p, position in sys_call_table = %d\n", symb, waddr, n ); 
             else 
                DEB( "symbol %s address = %p, not found in sys_call_table\n", symb, waddr ); 
          } 
       } 
       // -------------------------------------------------------- 
       ipdeny = ntohl( deny != NULL ? in_aton( deny ) : in_aton( "0.0.0.0" ) ); 
       LOG( "denied IP: %s\n", deny != NULL ? in4_ntoa( ipdeny ) : "no" ); 
       if( port != 0 ) 
          LOG( "denied TCP port: %d\n", port ); 
       if( NULL == ( taddr = find_sym( "sys_call_table" ) ) ) { 
          ERR( "sys_call_table not found\n" ); return -EINVAL; 
       } 
       DEB( "sys_call_table address = %p\n", taddr ); 
       show_in_table( "sys_accept" ); 
       show_in_table( "sys_connect" ); 
       show_in_table( "sys_socketcall" );                       // only diagnostic 
       old_sys_socketcall = (void*)taddr[ __NR_socketcall ]; 
       if( NULL == ( waddr = find_sym( "sys_socketcall" ) ) ) { // sys_socketcall not exported 
          ERR( "sys_socketcall not found\n" ); return -EINVAL; 
       } 
       if( old_sys_socketcall != waddr ) {                      // reinsurance! 
          ERR( "Oooops! I don't understand: addresses not equal\n" ); return -EINVAL; 
       } 
       if( debug ) show_cr0(); 
       rw_enable(); 
       taddr[ __NR_socketcall ] = new_sys_socketcall; 
       if( debug ) show_cr0(); 
       rw_disable(); 
       if( debug ) show_cr0(); 
       LOG( "install new sys_socketcall handler: %p\n", &new_sys_socketcall ); 
       return 0; 
    } 
     
    static void __exit exit( void ) { 
       LOG( "sys_socketcall handler before unload: %p\n", (void*)taddr[ __NR_socketcall ] ); 
       rw_enable(); 
       taddr[ __NR_socketcall ] = old_sys_socketcall; 
       rw_disable(); 
       LOG( "restore old sys_socketcall handler: %p\n", (void*)taddr[ __NR_socketcall ] ); 
       return; 
    } 
    
    module_init( init ); 
    module_exit( exit ); 
    


    Код максимально упрощён, такие вещи, как макросы диагностики LOG(), ERR() уже показывались, отчасти, в предыдущих частях. Функция find() тоже уже обсуждалась. Для записи в защищённую от записи область таблицы sys_call_table существует, как минимум, 3-4 альтернативных варианта, все они назывались и давались ссылками в обсуждениях предыдущей части. Защита от выгрузки модуля на время обслуживания системных вызовов, путём инкремента счётчика ссылок модуля, тоже не показана (называлось в предыдущей части). Все эти подробности присутствуют в кодах прилагаемого архива. Кроме того, коды в архиве обильно пересыпаны комментариями, содержащими выдержки из исходников ядра, с указанием файлов в дереве кодов ядра — это подсказывает требуемые структуры данных.

    И всё же при всех упрощениях код остаётся достаточно громоздким (не сложным, а громоздким). Но можно и не вникать в собственно код, последовательность обработки модифицированных сетевых системных вызовов следующая:

    • взять под контроль (сменить обработчик) системного вызова sys_socketcall();
    • если код вызова (1-й параметр sys_socketcall()) равен SYS_ACCEPT или SYS_CONNECT, то скопировать из пространства пользователя 3-х элементный массив параметров unsigned long (в общем случае 6 элементов, для SYS_SENDMSG, например);
    • 2-й элемент массива (соответствующий 2-му параметру accept() или connect()), хоть он и выглядит как unsigned long — это указатель на struct sockaddr в адресном пространстве пользователя, вторым шагом доступа к параметрам копируем структуру из адресного пространства пользователя;
    • структура содержит параметры IP адрес и TCP порт, если они попадают в перечень запрещённых — возвращаем код ошибки и отменяется операция, если нет — вызываем оригинальный обработчик системного вызова;
    • для всех остальных (18-ти, не SYS_ACCEPT и SYS_CONNECT) сокетных вызовов просто осуществляем транзитом вызов оригинального sys_socketcall();
    • запросы, не относящиеся к протоколу IPv4 без модификации передаются сетевому стеку;

    Некоторую дополнительную сложность создаёт тот факт, что для вызова accept() проверку приходится выполнять дважды:
    • номер TCP порта раньше оригинального системного вызова, когда сервер начинает прослушивать не присоединенный сокет;
    • IP адрес источника после установления соединения для сокета, после возврата из функции оригинального системного вызова;

    Как это выглядит в работе? Как-то так:
    $ sudo insmod fwnet.ko deny=192.168.56.101 port=10000 debug=1 
    $ lsmod | head -n2 
    Module                  Size  Used by 
    fwnet                  13116  0 
    $ dmesg | tail -n10
    [  786.609568] ! denied IP: 192.168.56.101 
    [  786.609572] ! denied TCP port: 10000 
    [  786.613047] ! sys_call_table address = c15b4000 
    [  786.636336] ! symbol sys_accept address = c149a070, not found in sys_call_table 
    [  786.656437] ! symbol sys_connect address = c149a0a0, not found in sys_call_table 
    [  786.661444] ! symbol sys_socketcall address = c149acd0, position in sys_call_table = 102 
    [  786.663994] ! CR0 = 8005003b 
    [  786.664090] ! CR0 = 8004003b 
    [  786.664096] ! CR0 = 8005003b 
    [  786.664100] ! install new sys_socketcall handler: e1ad50d0 
    

    Естественно, для того, чтобы наблюдать работу сетевого фильтра ядра в действии, нам необходимы TCP клиент и сервер (например, ncat). Но для детального тестирования были подготовлены специальные ретранслирующий сервер (tcpserv) и клиент (tcpcli). Не считая некоторых мелочей, заточенных под эту работу, они ничего особенного не представляют и рассматриваться здесь не будут (но они есть в прилагаемом архиве).
    Вот как будут выглядеть некоторые из попыток установления запрещённых TCP соединений:

    — Запуск сервера, прослушивающего запрещённый порт:
    $ ./tcpserv -v -p10000 
    listening on the TCP port 10000 
    denied TCP port: Input/output error 
    $ dmesg | tail -n5 
    ...
    [11213.888556] ! accept before: port = 10000 
    [11213.888562] ! accept from denied port 10000 
    

    — Попытка подключения клиента к запрещённому порту:
    $ ./tcpcli -v -h 127.0.0.1 -p 10000 
    client: can't connect to server: Permission denied 
    $ dmesg | tail -n5 
    ...
    [10984.082051] ! connect to 127.0.0.1:10000 
    [10984.082060] ! connect to 127.0.0.1:10000 denied 
    [11166.236948] ! connect to 127.0.0.1:53 
    ...
    

    Ну и так далее — задача предоставляет широкое и увлекательное поле для экспериментирования…

    (Здесь в протоколе специально сохранено и показано обращение в это же время к DNS по порту 53. Точно также, во время экспериментов с фильтрацией можно наблюдать множество соединений к TCP порту 80 — всё время не нарушая работы идёт HTTP трафик.)

    Важно то, что после выгрузки модуля работа системы восстанавливается в исходное состояние:
    $ sudo rmmod fwnet
    $ dmesg | grep \! | tail -n2
    [ 2890.602419] ! sys_socketcall handler before unload: e1ad50d0 
    [ 2890.602439] ! restore old sys_socketcall handler: c149acd0 
    

    Обсуждение


    Вот так, несколько с выдумкой, осуществляется в Linux обработка сетевых системных вызовов … по крайней мере, в 32 бит реализации. При первом столкновении с этими системными вызовами способ их работы несколько обескураживает.

    Эта часть обсуждения получилась затянутой и скучной, но такой артефакт, как вот такая работа системных вызовов — его нужно знать и учитывать.

    Маленький архив кода (и обширный журнал тестирования) для экспериментов можно взять здесь или здесь.
    • +22
    • 16,3k
    • 2
    Поделиться публикацией
    Похожие публикации
    Реклама помогает поддерживать и развивать наши сервисы

    Подробнее
    Реклама
    Комментарии 2
    • +1
      Шедевральный труд :) Спасибо, никогда не читал таких глубоких статей на русском! Еще очень понравилась тема приложения-примера, мы пытались нечто такое делать, но из юзерспейса — github.com/FastVPSEestiOu/linux_network_activity_tracker Конечно, в kernel space такое реализовывать красивее и как показала Ваша статья — проще.

      Еще раз спасибо :)
      • 0
        Я внёс минимальные правки в текст: подправил ссылки и, главное, добавил ссылки на архив кода, чтобы его можно будет взять для экспериментирования или как отправную точку для дальнейшего развития (там же, в архиве — логи очень большого числа тестирования как на виртуальных, так и на реальных машинах).

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