Linux, Linux kernel, программист
2 октября 2015 в 23:07

Разработка → Сетевые системные вызовы. Часть 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 бит реализации. При первом столкновении с этими системными вызовами способ их работы несколько обескураживает.

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

Маленький архив кода (и обширный журнал тестирования) для экспериментов можно взять здесь или здесь.
Олег Цилюрик @Olej
карма
3,7
рейтинг 0,0
Linux, Linux kernel, программист
Реклама помогает поддерживать и развивать наши сервисы

Подробнее
Спецпроект

Самое читаемое Разработка

Комментарии (2)

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

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

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