Сетевые системные вызовы. Часть 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 бит реализации. При первом столкновении с этими системными вызовами способ их работы несколько обескураживает.
Эта часть обсуждения получилась затянутой и скучной, но такой артефакт, как вот такая работа системных вызовов — его нужно знать и учитывать.