Добавить системный вызов. Часть 4 и последняя
- Что-то беспокоит меня Гондурас... - Беспокоит? А ты его не чеши.
В предыдущих частях обсуждения (1-я, 2-я и 3-я) мы рассматривали как, используя возможность поменять содержимое sys_call_table, изменить поведение того или иного системного вызова Linux. Сейчас мы продолжим эксперименты в сторону того, можно ли (и как) динамически добавить новый системный вызов в целях вашего программного проекта.
Мы не станем акцентироваться на вопросе «зачем?» — в программировании последнее дело спрашивать «зачем?», нужно спрашивать «как?»: если какая-то техника вам не близка — вы её просто не используйте (см. эпиграф). Но тем не мене, мы вернёмся коротко к этому к концу, в обсуждении.
При общей похожести на обсуждавшиеся ранее примеры подмены системного вызова, эта задача, при всём её сходстве, имеет некоторые отягчающие особенности:
- Размер оригинальной таблицы системных вызовов sys_call_table медленно но монотонно увеличивается от версии к версии ядра и существенно зависит от конкретной процессорной платформы.
- Константа, задающая размерность этой таблицы (известная в ядре как __NR_syscall_max, или в некоторых новых версиях как __NR_syscalls), объявлена препроцессорной константой (макросом) периода компиляции, и неизвестна во время выполнения (по крайней мере, мне неизвестна).
- Пытаясь добавить собственную точку входа в конец таблицы, мы имеем существенные риск выйти за пределы области, выделенной таблице — этого делать нельзя!
Размер таблицы sys_call_table достаточно велик, и он меняется от версии к версии ядра (версия 3.13), вот его очень грубая оценка:
$ cat /proc/kallsyms | grep ' sys_' | grep T | wc -l
357
О версиях ядра в этой части обсуждения придётся упоминать постоянно: то, что определялось в заголовочном файле предыдуще версии, может к следующей версии определяться по-другому и совсем в другом месте (файле), а то и вовсе явно не определяться. Это обычная практика в кодах ядра. Но при всём том, все основные принципы и зависимости остаются неизменными от версии к версии.
Смягчает выше перечисленные ограничивающие обстоятельства то, что таблица системных вызовов не плотная, достаточно сильно разреженная, в ней есть не использующиеся позиции (оставшиеся от устаревших системных вызовов и не поддерживаемых в настоящее время). Все такие позиции заполнены одним адресом — указателем на функцию обработчика нереализованных вызовов sys_ni_syscall():
$ cat /proc/kallsyms | grep sys_ni_syscall
c045b9a8 T sys_ni_syscall
А сам системный вызов sys_ni_syscall() определён как-то так:
asmlinkage long sys_ni_syscall( void ) {
return -ENOSYS;
}
Следовательно, мы можем добавить свой новый обработчик системного вызова в любую неиспользуемую позицию таблицы sys_call_table. Обратим внимание на то, что в этих позициях находятся не устаревшие, неиспользуемые вызовы, а помещён именно вызов, единственным действием которого является возврат кода ошибки. Более того, разработчики ядра не имеют права повторно использовать эти позиции, иначе совершенно устаревшие приложение могло бы вызывать, того не подозревая, новый замещающий вызов.
Статически, текстуально в исходном коде, можно в деталях рассмотреть структуру таблицы sys_call_table (для выбранной платформы и версии). Для таких изучений мало пригоден сам исходный код как он представлен разработчиками, но, к счастью для наших целей, на сегодня существует весьма много ресурсов, визуализирующих код ядра средствами проекта LXR (Linux Kernel Cross Reference), например здесь или здесь (это позволяет сравнивать версии и с лёгкостью находить нужные идентификаторы). Я для примера покажу только те позиции sys_call_table ядра 3.0.26 архитектуры x86 которые содержат (файл <arch/x86/kernel/syscall_table_32.S>) ссылку на sys_ni_syscall (но к ядру 3.2 и далее этот файл исчезнет даже из дерева кодов… но принципы формирования таблицы останутся те же и вид её не изменится):
ENTRY(sys_call_table)
.long sys_restart_syscall /* 0 - old "setup()" system call, used for restarting */
.long sys_exit
...
.long sys_ni_syscall /* old break syscall holder */ //17
.long sys_ni_syscall /* old stty syscall holder */ //31
.long sys_ni_syscall /* old gtty syscall holder */ //32
.long sys_ni_syscall /* 35 - old ftime syscall holder */ //35
.long sys_ni_syscall /* old prof syscall holder */ //44
.long sys_ni_syscall /* old lock syscall holder */ //53
.long sys_ni_syscall /* old mpx syscall holder */ //56
.long sys_ni_syscall /* old ulimit syscall holder */ //58
.long sys_ni_syscall /* old profil syscall holder */ //98
.long sys_ni_syscall /* old "idle" system call */ //112
.long sys_ni_syscall /* old "create_module" */ //127
.long sys_ni_syscall /* 130: old "get_kernel_syms" */ //130
.long sys_ni_syscall /* reserved for afs_syscall */ //137
.long sys_ni_syscall /* Old sys_query_module */ //167
.long sys_ni_syscall /* reserved for streams1 */ //188
.long sys_ni_syscall /* reserved for streams2 */ //189
.long sys_ni_syscall /* reserved for TUX */ //222
.long sys_ni_syscall //223
.long sys_ni_syscall //251
.long sys_ni_syscall /* sys_vserver */ //273
.long sys_ni_syscall /* 285 */ /* available */ //285
...
.long sys_setns // 346
В листинге показаны только неиспользуемые позиции (за исключением начала и конца таблицы), комментарии оставлены из исходного кода, а последний комментарий, с номером позиции системного вызова, добавлен мной.
Видим, что для этой версии ядра таблица имеет 347 позиций системных вызовов, из которых 21 не задействованы. Анализу неиспользуемых позиций в динамике, не полагаясь на изменчивые коды ядра, и будет посвящён первый рассматриваемый модуль ядра:
static void **taddr, // адрес sys_call_table
*niaddr; // адрес sys_ni_syscall()
static int nsys = 0; // число систеных вызовов в версии
#define SYS_NR_MAX 450
// SYS_NR_MAX - произвольно большое, больше длины sys_call_table
static int sys_length( void* data, const char* sym, struct module* mod, unsigned long addr ) {
int i;
if( ( strstr( sym, "sys_" ) != sym ) ||
( 0 == strcmp( "sys_call_table", sym ) ) ) return 0;
for( i = 0; i < SYS_NR_MAX; i++ ) {
if( taddr[ i ] == (void*)addr ) { // найден sys_* в sys_call_table
if( i > nsys ) nsys = i;
break;
}
}
return 0;
}
static void put_entries( void ) {
int i, ni = 0;
char buf[ 200 ] = "";
for( i = 0; i <= nsys; i++ )
if( taddr[ i ] == niaddr ) {
ni++;
sprintf( buf + strlen( buf ), "%03d, ", i );
}
LOG( "found %d unused entries: %s\n", ni, buf );
}
static int __init init_driver( void ) {
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 );
if( NULL == ( niaddr = (void*)kallsyms_lookup_name( "sys_ni_syscall" ) ) ) {
ERR( "sys_ni_syscall found!\n" );
return -EFAULT;
}
LOG( "sys_ni_syscall address = %p\n", niaddr );
kallsyms_on_each_symbol( sys_length, NULL );
LOG( "sys_call_table length = %d\n", nsys + 1 );
put_entries();
return -EPERM;
}
module_init( init_driver );
Как и раньше, необязательные детали (такие как макрос LOG() и др.) не показаны, все они есть в полных прилагаемых файлах.
Можно было бы пойти проще (что тоже корректно) — для выяснения протяжённости sys_call_table просто пересчитать число символов ядра по маске sys_* и вычесть 1 (сам символ sys_call_table). Но мы идём избыточным путём:
- в цикле находится очередной символ по маске sys_*;
- его позиция разыскивается в sys_call_table (это дополнительная перестраховка, что это системный вызов);
- еси эта позиция больше, чем найденные ранее для предыдущих символов, то на считается текущим номером последнего вызова (текущим размером sys_call_table);
Такая избыточная (но вовсе не необходимая) схема позволяет попутно уточнить точный размер таблицы системных вызовов для ваши архитектуры и версии ядра Linux:
$ uname -p
i686
$ uname -r
3.13.0-37-generic
$ sudo insmod nsys.ko
insmod: ERROR: could not insert module nsys.ko: Operation not permitted
$ dmesg | tail -n 4
[10751.601851] ! sys_call_table address = c1666140
[10751.602194] ! sys_ni_syscall address = c1075930
[10751.659769] ! sys_call_table length = 351
[10751.659779] ! found 27 unused entries: 017, 031, 032, 035, 044, 053, 056, 058, 098, 112, 127, 130, 137, 167, 169, 188, 189, 222, 223, 251, 273, 274, 275, 276, 285, 294, 317,
Итого, в этой версии 351 системных вызовов, из которых 27 не используются (почти 10% размера таблицы). Стабильность этого списка очень высока (сознательно для анализа кода была выбрана версия 3.0.26, а для исполнения в динамике версии 2.6.32 и 3.13, отстоящие друг от друга более чем на 4 года выпуска).
Примечание: Не отвлекаясь в сторону, отметим тем не менее вскользь, что написание модуля в подобной манере, который а). не предназначен для загрузки вообще, б). и в связи с этим сознательно возвращает не нулевой код завершения, в). а потому и вообще не имеет функции выгрузки (__exit) — это прямой эквивалент пользовательского приложения (начинающегося от точки main()), но только выполняющегося в режиме супервизора, с максимальными привилегиями. Но это уже предмет для другого разговора…
Теперь мы готовы возвратиться к реализации сформулированной задачи: добавить новый системный вызов. Естественно, нам также понадобится и тестовое приложение пользовательского пространства использующее такой вызов. Номер нового вызова определён в общем заголовочном файле (syscall.h), для согласованности использования модулем и программой (там же и упоминавшиеся макросы LOG(), ERR() и другая мелочёвка):
// номер нового добавляемого системного вызова
#define __NR_own 223
// может быть взят любой, полученный при загрузке модуля nsys.ko
// для ядра 3.31 был получен ряд из 27 позиций:
// 017, 031, 032, 035, 044, 053, 056, 058, 098, 112,
// 127, 130, 137, 167, 169, 188, 189, 222, 223, 251,
// 273, 274, 275, 276, 285, 294, 317,
Проще и понятнее начать именн с пользовательского приложения, которое будет выполнять новый системный вызов. Здесь всё просто — проще не бывает:
static void do_own_call( char *str ) {
int n = syscall( __NR_own, str, strlen( str ) );
if( n == 0 ) LOG( "syscall return %d\n", n );
else {
ERR( "syscall error %d : %s\n", n, strerror( -n ) );
exit( n );
}
}
int main( int argc, char *argv[] ) {
if( 1 == argc ) do_own_call( "DEFAULT STRING" );
else
while( --argc > 0 ) do_own_call( argv[ argc ] );
return EXIT_SUCCESS;
};
Программа может делать один или серию (если указать несколько параметров в командной строке) системных вызовов и передаёт символьный параметр в вызов (подобно тому, как это делает, например sys_write). А уже в коде модуля мы сможем видеть как эта строка копируется в пространство ядра. Но главным интересом здесь есть код возврата: успех или неудача выполнения системного вызова.
А вот и модуль, который «подхватывает» такой вызов со стороны ядра:
// системный вызов с двумя параметрами:
asmlinkage long (*old_sys_addr) ( const char __user *buf, size_t count );
asmlinkage long new_sys_call ( const char __user *buf, size_t count ) {
static char buf_msg[ 80 ];
int res = copy_from_user( buf_msg, (void*)buf, count );
buf_msg[ count ] = '\0';
LOG( "accepted %d bytes: %s\n", count, buf_msg );
return res;
};
static void **taddr; // адрес таблицы sys_call_table
static int __init new_sys_init( void ) {
void *waddr;
if( NULL == ( taddr = (void**)kallsyms_lookup_name( "sys_call_table" ) ) ) {
ERR( "sys_call_table not found!\n" );
return -EFAULT;
}
old_sys_addr = (void*)taddr[ __NR_own ];
if( ( waddr = (void*)kallsyms_lookup_name( "sys_ni_syscall" ) ) != NULL )
LOG( "sys_ni_syscall address = %p\n", waddr );
else {
ERR( "sys_ni_syscall not found!\n" );
return -EFAULT;
}
if( old_sys_addr != waddr ) {
ERR( "not free slot!\n" );
return -EINVAL;
}
LOG( "old sys_call_table[%d] = %p\n", __NR_own, taddr[ __NR_own ] );
rw_enable();
taddr[ __NR_own ] = new_sys_call;
rw_disable();
LOG( "new sys_call_table[%d] = %p\n", __NR_own, taddr[ __NR_own ] );
return 0;
}
static void __exit new_sys_exit( void ) {
rw_enable();
taddr[ __NR_own ] = old_sys_addr;
rw_disable();
LOG( "restore sys_call_table[%d] = %p\n", __NR_own, taddr[ __NR_own ] );
return;
}
module_init( new_sys_init );
module_exit( new_sys_exit );
Здесь также делается двойная перестраховка — проверка соответствия адреса в заданной (__NR_own) позиции таблицы sys_call_table адресу неиспользуемых системных вызовов sys_ni_syscall.
И теперь оцениваем то, что у нас получилось:
$ ./syscall
syscall error -1 : Operation not permitted
$ echo $?
255
$ sudo insmod adds.ko
$ lsmod | head -n3
Module Size Used by
adds 12622 0
pci_stub 12550 1
$ dmesg | tail -n3
[15000.600618] ! sys_ni_syscall address = c1075930
[15000.600622] ! old sys_call_table[223] = c1075930
[15000.600623] ! new sys_call_table[223] = f87d9000
$ ./syscall new string for call
syscall return 0
syscall return 0
syscall return 0
syscall return 0
$ dmesg | tail -n4
[15070.680753] ! accepted 4 bytes: call
[15070.680799] ! accepted 3 bytes: for
[15070.680804] ! accepted 6 bytes: string
[15070.680807] ! accepted 3 bytes: new
$ ./syscall 'new string for call'
syscall return 0
$ dmesg | tail -n1
[15167.526452] ! accepted 19 bytes: new string for call
$ sudo rmmod adds
$ dmesg | tail -n1
[15199.917817] ! restore sys_call_table[223] = c1075930
$ ./syscall
syscall error -1 : Operation not permitted
После выгрузки модуля ядро более не в состоянии поддержать выполнение требуемого программе системного вызова!
Обсуждать тут, собственно, нечего — всё прозрачно показано примером. Но я вначале обещал высказать свои соображения зачем такое вообще может иметь применение (но ещё раз повторю своё твёрдое убеждение в том, что вопрос «зачем?» в программировании, в общем случае, бессмысленный). Показанный трюк предоставляет ещё один путь взаимодействия (двухстороннего) приложений с ядром. Да, конечно есть возможность сделать то же через /dev, /proc, или /sys … но каждый из этих способов тяжеловеснее, чем системный вызов, он вовлекает в работу большее число промежуточных механизмов ядра.
Когда представляется возможность использовать подобный механизм? Например, для асинхронных уведомлений приложения о некоторых событиях в ядре, когда отдельный поток приложения заблокирован на системном вызове до наступления ожидаемого события. Таким событием может быть, например, аппаратное прерывание (IRQ) от отлаживаемого нового устройства (в меру не быстрого). При таком подходе любые операции ввода-вывода с устройством можно реализовать из пространства пользователя, используя операции группы inb(), outb()…, или ioperm() и iopl(). Всё это вместе даёт возможность изучить работу и выписать код обмена с устройством в самых тонких деталях не выходя за пределы пространства пользователя, без рисков и сложностей, связанных с привилегированным режимом ядра. А дальше уже по обстоятельствам и по желанию: можно механически переписать код этого оттестированного драйвера в форме модуля, или оставить как он и есть в пользовательском пространстве.
Примечание: Замечание выше о низкой скорости устройств, которые только и можно отрабатывать подобным образом, тоже не следует принимать слишком близко к сердцу. По настоящему высокоскоростные устройства и внутри ядра Linux не работают по прерываниям, а используют циклический программный опрос. Как, например, все сетевые интерфейсы сетевого стека на аппаратном уровне … кто знает сетевую подсистему Linux тот поймёт о чём это я.
Я уже не говорю о разработчиках проприетарного железа и проектов, которые имеют такие же права на существование в природе, как и прочие. В их работах подобная техника может найти почву для применения.
И опять же, как ранее, архив кода можно взять здесь или здесь…
Поскольку это заключительная часть небольшого цикла о таком непривычном (неприличном?) обращении с системными вызовами Linux, то хотелось бы в два слова высказать в порядке общего итога рассказанного.
Когда приступаешь к написанию модулей ли ядра, или патчей к ядру, первоначально возникает ощущение скованности, ограниченности только теми возможностями, которые предоставляет плохо документированный API ядра, либо описаны в немногочисленных и давно устаревших книгах по типу «написание драйверов Linux». Но опыты, подобные описанным в этом цикле, и ещё множество иных подобных, подсказывают, что в модуле ядра вы имеете в доступе все (без исключения!) возможности пространства пользователя (запуск новых процессов и потоков, посылка сигналов UNIX др.). И плюс к этому недостижимые в пользовательском пространстве возможности, связанные с привилегированным (супервизор, кольцо 0) режимом защиты процессора (привилегированные команды, внутренние регистры процессора, реакция на прерывания).
Показать это — вот главная цель этого цикла статей, а вовсе не всего лишь частные задачи подмены или добавления системных вызовов. Программирование в режиме ядра должно создавать такое ощущение свободы, что здесь вы подобны богам и можете здесь всё. Но это же требует и адекватной степени ответственности…