[Из песочницы] Делаем доступным все символы ядра Linux. Часть 1
Это обсуждение относится к ядру операционной системы Linux, и представляет интерес для разработчиков модулей ядра, драйверов под эту операционную систему. Для всех прочих эти заметки вряд ли представляют интерес.
Каждый, кто написал свой самый простейший модуль ядра Linux знает, и это написано во всех существующих книгах по технике написания драйверов Linux, что использовать в собственном коде модуля можно только те имена (главным образом это функции API ядра), которые экспортируются ядром. Это одно из самых путанных понятие из области ядра Linux — экспорт символов ядра. Для того, чтобы имя из пространства ядра было доступно для связывания в другом модуле, для этого имени должны выполняться два условия: а). имя должно иметь глобальную область видимости (в вашем модуле такие имена не должны объявляться static) и б). имя должно быть явно объявлено экспортируемым, оно должно быть явно записано параметром макроса EXPORT_SYMBOL (или EXPORT_SYMBOL_GPL, что далеко не одно и то же по последствиям).
Все имена, известные в ядре, динамически отображаются в псевдо-файле /proc/kallsyms, и число их огромно:
$ uname -r
3.13.0-37-generic
$ cat /proc/kallsyms | wc -l
108960
Число же экспортируемых ядром имён (предоставляемых для использования в программном коде модулей) значительно меньше:
$ cat /lib/modules/`uname -r`/build/Module.symvers | wc -l
17533
Как легко видеть, в ядре определено несколько сот тысяч имён (в зависимости от версии ядра). Но только малая часть (порядка 10%) этих имён объявлены как экспортируемые, и доступны для использования (связывания) в коде модулей ядра.
Вспомним, что вызовы API ядра осуществляются по абсолютному адресу размещения имени. Каждому экспортированному ядром (или любым модулем) имени соотносится адрес, он и используется для связывания при загрузке модуля, использующего это имя. Это основной механизм взаимодействия модуля с ядром. При выполнении системы модуль динамически загружается и становится неотъемлемой частью кода ядра. Этим объясняется то, что модуль ядра в Linux может быть скомпилирован только под конкретное ядро (обычно по месту установки), а попытка загрузить такой бинарный модуль с другим ядром приведёт к краху операционной системы.
Как итог этого краткого экскурса, мы можем сформулировать, что разработчики ядра Linux предоставляют для разработчиков расширений (модулей ядра) весьма ограниченный (и крайне плохо документированный) набор API, который, по их мнению, достаточен для написания расширений ядра. Но это мнение может не совпадать с мнением самих разработчиков драйверов, которые хотели бы иметь в руках весь арсенал ядра. И воспользоваться ним вполне возможно, обсуждением чего мы и займёмся в оставшейся части текста.
Взглянем на структуру строки-записи любого (из 108960 ) имени ядра в /proc/kallsyms:
$ sudo cat /proc/kallsyms | grep ' T ' | grep sys_close
c1176ff0 T sys_close
Это экспортируемое имя обработчика системного вызова (POSIX) close(). (В некоторых дистрибутивах Linux адреса в строке будут заполнены только если считывание выполняется с правами root, для других пользователей в поле адреса будет показано нулевое значение.)
Мы вполне могли бы использовать вызов функции sys_close() в коде своего модуля. Но мы не сможем сделать это с совершенно симметричным ему вызовом sys_open(), потому что это имя не экспортируется ядром. При сборке такого модуля мы получим предупреждение подобно следующему:
$ make
...
MODPOST 2 modules
WARNING: "sys_open" [/home/olej/2011_WORK/LINUX-books/examples.DRAFT/sys_call_table/md_0o.ko]
undefined!
...
Но попытка загрузить такой модуль закончится неудачей:
$ sudo insmod md_0o.ko
insmod: error inserting 'md_0o.ko': -1 Unknown symbol in module
$ dmesg
md_0o: Unknown symbol sys_open
Такой модуль не может быть загружен, потому как он противоречит правилам целостности ядра: содержит не разрешённый внешний символ — этот символ не экспортируется ядром для связывания (то-есть предупреждение с точки зрения компилятора выглядит как критическая ошибка с точки зрения разработчика).
Означает ли показанное выше, что только экспортируемые символы ядра доступны в коде нашего модуля. Нет, это означает только, что рекомендуемый способ связывания по имени (по абсолютному адресу имени) применим только к экспортируемым именам. Экспортирование обеспечивает ещё один дополнительный рубеж контроля для обеспечения целостности ядра — минимальная некорректность приводит к полному краху операционной системы, иногда при этом она даже не успевает сделать сообщение: Oops…
Раз в псевдо-файле /proc/kallsyms отображаются все символы ядра, то код модуля мог бы взять их оттуда. Более того, это значит, что в API ядра есть методы локализации всех имён, и эти методы можно использовать в своём коде для этих же целей. Опуская путь промежуточных решений, рассмотрим только 2 варианта, 2 экспортируемых вызова (все определения в <linux/kallsyms.h> в ядре, или см. lxr.free-electrons.com/source/include/linux/kallsyms.h):
Вызов:
unsigned long kallsyms_lookup_name( const char *name );
Здесь name — имя которое мы ищем, а возвращается его абсолютный адрес. Недостаток этого варианта в том, что он появляется в ядре где-то между версиями ядра 2.6.32 и 2.6.35 (или примерно между пакетными дистрибутивами издания лета 2010г. и весны 2011г.), точнее он присутствовал и ранее, но не экспортировался. Для встраиваемых и малых систем это может стать серьёзным препятствием.
Более общий вызов:
int kallsyms_on_each_symbol( int (*fn)(void*, const char*, struct module*, unsigned long), void *data );
Этот вызов сложнее, и здесь нужны краткие пояснения. Первым параметром (fn) он получает указатель на вашу пользовательскую функцию, которая и будет последовательно (в цикле) вызываться для всех символов в таблице ядра, а вторым (data) — указатель на произвольный блок данных (параметров), который будет передаваться в каждый вызов этой функции fn().
Прототип пользовательской функции fn, которая циклически вызывается для каждого имени:
int func( void *data, const char *symb, struct module *mod, unsigned long addr );
Здесь:
data — блок параметров, заполненный в вызывающей единице, и переданный из вызова функции kallsyms_on_each_symbol() (2-й параметр вызова), как это описано выше, здесь, как раз, и хорошо передать имя того символа, который мы разыскиваем;
symb — символьное изображение (строка) имени из таблицы имён ядра, которое обрабатывается на текущем вызове func;
mod — модуль ядра, к которому относится обрабатываемый символ;
addr — адрес символа в адресном пространстве ядра (это, собственно, и есть то, что мы и ищем);
Перебор имён таблицы ядра можно прервать на текущем шаге и дальше уже не продолжать (из соображений эффективности, если мы уже обработали требуемые нам символы), если пользовательская функция func возвратит ненулевое значение.
Для пользования вызовом kallsyms_on_each_symbol() мы подготовим собственную функцию обёртку, аналогичную по смыслу kallsyms_lookup_name():
static void* find_sym( const char *sym ) { // find address kernel symbol sym
static unsigned long faddr = 0; // static !!!
// ----------- nested functions are a GCC extension ---------
int symb_fn( void* data, const char* sym, struct module* mod, unsigned long addr ) {
if( 0 == strcmp( (char*)data, sym ) ) {
faddr = addr;
return 1;
}
else return 0;
};
// --------------------------------------------------------
kallsyms_on_each_symbol( symb_fn, (void*)sym );
return (void*)faddr;
}
Здесь использован трюк с вложенным определением функции symb_fn(), что является совершенно легальным использованием расширения компилятора GCC (относительно стандарта языка C), но для компиляции модулей ядра мы используем исключительно GCC. Такой код позволяет избежать объявления глобальной промежуточной переменной, препятствует засорению пространства имён и способствует локализации кода.
Одним из самых сакральных мест в операционной системе Linux является селекторная таблица sys_call_table, через которую происходит любой системный вызов: подготовив предварительно соответствующим образом параметры, записав 1-м параметром номер (селектор) системного вызова, система выполняет команду перехода в ядро: int 80h (в старых версиях) или sysenter, что по существу одно и то же. Номер системного вызова (селектор, 1-й параметр) и является индексом в таблице sys_call_table (массиве) указателей на функции обработки системных вызовов ядром. Номера всех системных вызовов мы можем посмотреть, например, для архитектуры i386:
$ cat /usr/include/i386-linux-gnu/asm/unistd_32.h
...
#define __NR_restart_syscall 0
#define __NR_exit 1
#define __NR_fork 2
#define __NR_read 3
#define __NR_write 4
#define __NR_open 5
#define __NR_close 6
#define __NR_waitpid 7
#define __NR_creat 8
...
Здесь изображена таблица индексов (номеров) системных вызовов, используемая в адресном пространстве пользователя, реализуемая стандартной библиотекой C libc.so. Точный аналог этой таблицы присутствует и в заголовочных файлах ядра, в адресном пространстве ядра. И аналогичные таблицы индексов системных вызовов наличествуют для всех архитектур, поддерживаемых Linux (таблицы для разных архитектур различаются и размерностью, и составом, и численными значениями индексов для аналогичных вызовов!).
Начиная с версий 2.6 ядра символ sys_call_table был исключён из числа экспортируемых, исходя из весьма своеобразно понимаемых командой разработчиков ядра соображений защищённости (могу предположить, что защищённость здесь предполагалось толковать в смысле: защищённость куска хлеба разработчиков ядра от сторонних программистов). Все книги по написанию драйверов Linux утверждают, что использовать sys_call_table в коде драйвера невозможно. Сейчас, а ещё больше в последующих частях обсуждения, мы будем показывать что это не так!
Достаточно продолжительное время (с 2011 года) работая с обсуждаемой тематикой я перечитал множество публикаций на этот предмет. Вирусописатели и всякая прочая шваль, пугающие самих себя страшным словом хакер, чего только не выдумывали для поиска sys_cal_table — даже динамически декодируют дампы двоичных фрагментов памяти, занимаемых ядром, проделывая сканирование участков памяти ядра (в поисках, например, позиции sys_close(), которй экспортируется всегда). Как будет сейчас показано, всё это делается куда как проще. Только секрет устойчивости Linux состоит не в том. что пакостники не могут чего-то там найти, а в том, что регламентация прав доступа не позволит (без root прав) сделать никакие гадости за пределами этого регламента … а root права никто пакостникам не даёт.
Но вернёмся к задаче разрешения не экспортируемых символов ядра. Первый вариант (файл mod_kct.c) демонстрирует использование kallsyms_lookup_name() (для простоты и укорочения не показаны включение заголовочных файлов, необходимые макросы вида MODULE_*() … — всё это есть в файлах архива):
static int __init ksys_call_tbl_init( void ) {
void** sct = (void**)kallsyms_lookup_name( "sys_call_table" );
printk( "+ sys_call_table address = %p\n", sct );
if( sct ) {
int i;
char table[ 120 ] = "sys_call_table : ";
for( i = 0; i < 10; i++ )
sprintf( table + strlen( table ), "%p ", sct[ i ] );
printk( "+ %s ...\n", table );
}
return -EPERM;
}
module_init( ksys_call_tbl_init );
Здесь извлекается адрес таблицы sys_call_table и далее содержащиеся в ней адреса обработчиков первых 10-ти системных вызовов (__NR_restart_syscall … __NR_link):
$ sudo insmod mod_kct.ko
insmod: ERROR: could not insert module mod_kct.ko: Operation not permitted
$ dmesg | tail -n 2
[39473.496040] + sys_call_table address = c1666140
[39473.496045] + sys_call_table : c1067840 c1059280 c1055eb0 c1179ee0 c1179f70 c1178cb0 c1176ff0 c1059570 c1178d10 c1188860 ...
(Ошибка 'Operation not permitted ' не должна смущать — мы и не собирались загружать модуль, на что и указывает ненулевой код возврата -EPERM, мы просто выполняем свой код в привилегированном режиме, супервизоре, нулевом кольце защиты процессора).
Удостоверимся, чему соответствуют найденные адреса, занесённые в начало массива sys_call_table:
$ sudo cat /proc/kallsyms | grep c1067840
c1067840 T sys_restart_syscall
$ sudo cat /proc/kallsyms | grep c1059280
c1059280 T SyS_exit
c1059280 T sys_exit
$ sudo cat /proc/kallsyms | grep c1055eb0
c1055eb0 T sys_fork
… ну и так далее (сравните с таблицей номеров системных вызовов, показанной ранее).
Следующий вариант будет чуть сложнее для понимания, он использует функцию kallsyms_on_each_symbol(), но он и более универсальный (файл mod_koes.c):
static int __init ksys_call_tbl_init( void ) {
void **sct = find_sym( "sys_call_table" ); // table sys_call_table address
printk( "+ sys_call_table address = %p\n", sct );
if( sct != NULL ) {
int i;
char table[ 120 ] = "sys_call_table : ";
for( i = 0; i < 10; i++ )
sprintf( table + strlen( table ), "%p ", sct[ i ] );
printk( "+ %s ...\n", table );
}
return -EPERM;
}
module_init( ksys_call_tbl_init );
Текстуально он почти полностью повторяет предыдущий, всю продуктивную работу выполняет функция find_sym(), которая приведена и обсуждалась выше. Результат выполнения неизменно тот же:
$ sudo insmod mod_koes.ko
insmod: ERROR: could not insert module mod_koes.ko: Operation not permitted
$ dmesg | tail -n2
[42451.186648] + sys_call_table address = c1666140
[42451.186654] + sys_call_table : c1067840 c1059280 c1055eb0 c1179ee0 c1179f70 c1178cb0 c1176ff0 c1059570 c1178d10 c1188860 ...
Скептик может возразить: «Ну и что?». А то, что показаны необходимые и достаточные механизмы для того, чтобы использовать любые API ядра в собственно коде модулей ядра, подгружаемых динамически. Показанная техника расширяет спектр возможностей автора модуля ядра на порядки! Это настолько объёмные перспективы, что для их рассмотрения нам потребуются последующие части этого обсуждения.
…но чтобы завершение рассказа не было таким скучным, покажем одно из простых, но впечатляющих применений — выполнение кода системного вызова (вообще то говоря, любого) пользовательской библиотеки из кода модуля ядра.
Вам говорили, что код модуля ядра осуществляет вывод в системный журнал (printk()) и не может осуществлять вывод на терминал (printf())? Сейчас мы покажем, что это не так… Вот такой простой модуль ядра производит вывод на терминал:
static asmlinkage long (*sys_write) (
unsigned int, const char __user *, size_t );
static int __init wr_init( void ) {
char buf[ 80 ] = "Hello from kernel!\n";
int len = strlen( buf ), n;
sys_write = find_sym( "sys_write" );
printk( "+ sys_write address = %p\n", sys_write );
printk( "+ [%d]: %s", len, buf );
if( sys_write != NULL ) {
mm_segment_t fs = get_fs();
set_fs( get_ds() );
n = sys_write( 1, buf, len );
set_fs( fs );
printk( "+ printf() return : %d\n", n );
}
return -EPERM;
}
module_init( wr_init );
А вот его исполнение (попытка загрузки с аварийным кодом завершения):
$ sudo insmod mod_wrc.ko
Hello from kernel!
insmod: ERROR: could not insert module mod_wrc.ko: Operation not permitted
$ dmesg | tail -n3
[23942.974587] + sys_write address = c1179f70
[23942.974591] + [19]: Hello from kernel!
[23942.974612] + printf() return : 19
Первая строка здесь выведена системным вызовом write(). Естественно, что вывод производится на управляющий терминал пользовательского процесса insmod, но здесь важно то, что мы выполняем системный вызов write() из кода пространства ядра. Здесь некоторые детали могут потребовать дополнительных объяснений:
Откуда я взял такой «хитрый» прототип описания адресной переменной sys_write? Конечно, я бессовестно списал его из оригинального определения функции sys_write() в ядре, в заголовочном файле <linux/syscalls.h>, что и показано комментарием в коде (в полном коде, в архиве):
/* <linux/syscalls.h>
asmlinkage long sys_write( unsigned int fd,
const char __user *buf,
size_t count ); */
И только так следует поступать для всех используемых не экспортируемых имён ядра — списывая прототипы реализующих функций из соответствующих заголовочных файлов. Любое минимальное несоответствие прототипа приведёт к немедленному краху операционной системы!
Что означают несколько похожих вызовов вида: get_ds(), get_fs(), set_fs()? Это небольшой трюк, состоящий во временной подмене сегментов данных в ядре. Дело в том, что в прототипе обработчика системного вызова sys_write() стоит квалификатор __user, показывающий что указатель указывает на данные в пространстве пользователя. Код системного вызова проверяет принадлежность (только диапазону численного значения адреса), и если адрес указывает на область пространства ядра (как в нашем случае) вызовет аварийное завершение. Таким трюком мы показываем контролирующему коду, что наш адрес следует толковать как принадлежащий к пространству пользователя. В подобных случаях этот трюк можно использовать механически, не особенно задумываясь над его смыслом.
Эксперименты с подобными кодами, а тем более в более обстоятельных случаях, которые я предполагаю обсудить позже, чреваты неприятностями — даже незначительные ошибки в коде мгновенно заваливают операционную систему. Ещё хуже то, что система заваливается в неопределённом неустойчивом состоянии, и существует конечная (не высокая) вероятность того, что система не восстановится и после перезагрузки.
Во время экспериментов с подобными кодами меня всё время занимал вопрос: нельзя ли отработку и тестирование их выполнять в виртуальной машине? Это при том, то нам придётся выполнять (в последующем) очень машинно-зависимые вещи, такие как запись в скрытые аппаратные регистры процессора, например CR0.
С удовлетворением могу констатировать, что все обсуждаемые коды адекватно выполняются в виртуальных машинах в среде Oracle VirtualBox, по крайней мере, в относительно последних версиях, начиная от состояния 2013 года.
Поэтому настоятельно рекомендую работать с такими кодами первоначально в виртуальных машинах, дабы избежать серьёзных неприятностей.
Упоминание Oracle VirtualBox вовсе не означает, что это состояние дел не будет сохраняться в других менеджерах виртуальных машин, просто я не проверял коды в этих менеджерах (почти наверняка всё будет благополучно в QEMU/KVM, поскольку VirtualBox заимствует код виртуализации из QEMU).
Архив файлов (кодов) для экспериментов, который упоминается в тексте, можно взять здесь или
здесь.