В предыдущей части мы договорились до того, что не экспортируемые имена ядра Linux могут использоваться в коде собственных модулей ядра с тем же успехом, что и экспортируемые. Одним из таких имён в ядре является селекторная таблица всех системных вызовов Linux. Собственно, это и есть основной интерфейс любых приложений к сервисам ядра. Теперь мы рассмотрим как можно модифицировать оригинальный обработчик любого системного вызова, подменить его, или внести разнообразие в его выполнение в соответствии с собственным видением.
Техника модификации системных вызовов операционной системы известна давно и использовалась в самых разных операционных системах. Это любимая техника писателей вирусов начиная с системы MS-DOS — системы, которая просто провоцировала на такие эксперименты. Но мы будем использовать эту технику в мирных целях… (В разных публикациях такие действия называют по-разному: модификация, встраивание, имплементация, подмена, перехват — они имеют свои нюансы, но в нашем обсуждении могут использоваться как синонимы.)
Если любой системный вызов в ядре операционной системы Linux вызывается косвенно через адрес в таблице (массиве) системных вызовов sys_call_table, то подменив адрес в этой селекторной таблице на собственную функцию-обработчик, мы тем самым и подменим обработчик системного вызова. В этом, собственно, и состоит техника модификации. На практике радикализм до такой степени никогда не требуется, реально нам бывает необходимо выполнять оригинальный системный вызов, но проделав некоторые собственные действия либо до него (предобработка), либо после окончания (постобработка) его (либо комбинация того и другого).
В предыдущей части мы убедились, что можем находить размещение и использовать любые, в том числе и не экспортируемые, символы ядра. Всё что требуется, в принципе, для целей модификации системных вызовов — это найти базовый адрес массива sys_call_table, и по смещению номера требуемого системного вызова вызова записать адрес собственной функции обработки.
Реально схема будет чуть сложнее — необходимо прежде сохранить старое (оригинальное) значение системного обработчика:
— Для вызова оригинального обработчика из собственной функции обработки перед или/и после выполнения модифицированного кода;
— Для восстановления оригинального обработчика при выгрузке модуля.
Реализация, как это всегда и бывает, несколько сложнее теории. Первая, незначительная, сложность состоит в том как записать прототип собственной функции обработки конкретного системного вызова. Малейшая некорректность прототипа с большой вероятностью приведёт просто к краху операционной системы. Решение состоит в том, чтобы просто подсмотреть и списать (как двоечник) прототип функции обработки этого системного вызова из заголовочного файла ядра <linux/syscalls.h>.
Следующая, гораздо более существенная, трудность здесь состоит в том, что селекторная таблица sys_call_table, в процессорных архитектурах которые это позволяют (а I386 и X86_64 в их числе), размещается в страницах памяти, разрешённых исключительно для чтения (readonly). Это контролируется аппаратно (средствами MMU — Memory Management Unit) и при нарушении прав доступа возбуждается исключение. Поэтому нам нужно снять флаг запрета записи на время модификации элемента sys_call_table и восстановить его после.
В архитектурах I386 и X86_64 флаг разрешения записи определяется битовым флагом в скрытом регистре состояния процессора CR0. Для выполнения нужных нам действий мы используем соответствующе функции, для 32-бит архитектуры, например, они будут выглядеть так (файл CR0.c, этот код написан на инлайновых ассемблерных вставках — расширение компилятора GCC):
P.S. Различные варианты техники записи в защищённые от записи страницы обсуждались, например, в WP: Safe or Not? и Кошерный способ модификации защищённых от записи областей ядра Linux.
Теперь мы готовы заменить любой системный вызов Linux (man(2)) на свою собственную функцию-обработчик — а это и есть то, к чему мы и стремились. Для иллюстрации работоспособности метода мы заменим (расширим) системный вызов write( 1, … ) — вывод на терминал, задублируем поток вывода в системный журнал (подобно тому что делает команда tee):
Функцию поиска символа ядра find_sym(), использующую вызов API ядра kallsyms_on_each_symbol(), мы видели в предыдущей части обсуждения. Кроме того, мы делаем контроль (больше для иллюстрации) совпадение адреса имени оригинального sys_write() с этим же адресом, находящимся в позиции __NR_write таблицы sys_call_table.
Теперь мы можем исполнять систему с параллельным журналированием всего, что выводится на терминал (выбор для экспериментов write() не особо эстетично, но очень иллюстративно и, кроме того, безопасно на ранних стадиях экспериментирования в сравнении с другими системными вызовами Linux):
Подобным образом мы можем изменить поведение любого системного вызова Linux. Делается это динамически, загрузкой модуля, и при его выгрузке восстанавливается оригинальное поведение системы. Области применения такой техники широкие: возможности контроля и отладки в период разработки, целевое изменение поведения отдельных системных вызовов под задачи проекта и другое.
Показанный код заметно упрощён. Реальный модуль должен был бы предпринимать ряд страховочных действий для гарантий целостности. Например, новая функция-обработчик могла бы увеличить счётчик ссылок модуля вызовом try_module_get( THIS_MODULE ), чтобы предотвратить выгрузку модуля на время выполнения функции (что возможно с исчезающе малой, но всё-таки конечной вероятностью). Перед возвратом функция тогда проделает обратное действие: module_put( THIS_MODULE ). Могут понадобится и другие предосторожности, на время загрузки и выгрузки модуля, например. Но это достаточно обычная техника модулей ядра, и она не обсуждается дабы не усложнять принцип.
Некоторые дополнительные нюансы и особые случаи показанной техники мы увидим в седующей части обсуждения.
Архив кода для экспериментов можно взять здесь или здесь (из-за несущественности примеров я не размещаю их на GitHub).
P.S. Всё показанное работает в неизменном виде в 32-бит. В 64-бит архитектуре картина становится несколько сложнее за счёт необходимости эмуляции 32-битных приложений. Чтобы не усложнять картину, этот вариант сознательно не затрагивался (возможно пока, и к нему стоит вернуться позже).
Техника модификации
Техника модификации системных вызовов операционной системы известна давно и использовалась в самых разных операционных системах. Это любимая техника писателей вирусов начиная с системы MS-DOS — системы, которая просто провоцировала на такие эксперименты. Но мы будем использовать эту технику в мирных целях… (В разных публикациях такие действия называют по-разному: модификация, встраивание, имплементация, подмена, перехват — они имеют свои нюансы, но в нашем обсуждении могут использоваться как синонимы.)
Если любой системный вызов в ядре операционной системы Linux вызывается косвенно через адрес в таблице (массиве) системных вызовов sys_call_table, то подменив адрес в этой селекторной таблице на собственную функцию-обработчик, мы тем самым и подменим обработчик системного вызова. В этом, собственно, и состоит техника модификации. На практике радикализм до такой степени никогда не требуется, реально нам бывает необходимо выполнять оригинальный системный вызов, но проделав некоторые собственные действия либо до него (предобработка), либо после окончания (постобработка) его (либо комбинация того и другого).
В предыдущей части мы убедились, что можем находить размещение и использовать любые, в том числе и не экспортируемые, символы ядра. Всё что требуется, в принципе, для целей модификации системных вызовов — это найти базовый адрес массива sys_call_table, и по смещению номера требуемого системного вызова вызова записать адрес собственной функции обработки.
Реально схема будет чуть сложнее — необходимо прежде сохранить старое (оригинальное) значение системного обработчика:
— Для вызова оригинального обработчика из собственной функции обработки перед или/и после выполнения модифицированного кода;
— Для восстановления оригинального обработчика при выгрузке модуля.
Реализация
Реализация, как это всегда и бывает, несколько сложнее теории. Первая, незначительная, сложность состоит в том как записать прототип собственной функции обработки конкретного системного вызова. Малейшая некорректность прототипа с большой вероятностью приведёт просто к краху операционной системы. Решение состоит в том, чтобы просто подсмотреть и списать (как двоечник) прототип функции обработки этого системного вызова из заголовочного файла ядра <linux/syscalls.h>.
Следующая, гораздо более существенная, трудность здесь состоит в том, что селекторная таблица sys_call_table, в процессорных архитектурах которые это позволяют (а I386 и X86_64 в их числе), размещается в страницах памяти, разрешённых исключительно для чтения (readonly). Это контролируется аппаратно (средствами MMU — Memory Management Unit) и при нарушении прав доступа возбуждается исключение. Поэтому нам нужно снять флаг запрета записи на время модификации элемента sys_call_table и восстановить его после.
В архитектурах I386 и X86_64 флаг разрешения записи определяется битовым флагом в скрытом регистре состояния процессора CR0. Для выполнения нужных нам действий мы используем соответствующе функции, для 32-бит архитектуры, например, они будут выглядеть так (файл CR0.c, этот код написан на инлайновых ассемблерных вставках — расширение компилятора GCC):
// page write protect - on
#define rw_enable() \
asm( "cli \n" \
"pushl %eax \n" \
"movl %cr0, %eax \n" \
"andl $0xfffeffff, %eax \n" \
"movl %eax, %cr0 \n" \
"popl %eax" );
// page write protect - off
#define rw_disable() \
asm( "pushl %eax \n" \
"movl %cr0, %eax \n" \
"orl $0x00010000, %eax \n" \
"movl %eax, %cr0 \n" \
"popl %eax \n" \
"sti " );
P.S. Различные варианты техники записи в защищённые от записи страницы обсуждались, например, в WP: Safe or Not? и Кошерный способ модификации защищённых от записи областей ядра Linux.
Теперь мы готовы заменить любой системный вызов Linux (man(2)) на свою собственную функцию-обработчик — а это и есть то, к чему мы и стремились. Для иллюстрации работоспособности метода мы заменим (расширим) системный вызов write( 1, … ) — вывод на терминал, задублируем поток вывода в системный журнал (подобно тому что делает команда tee):
#define PREFIX "! "
#define DEB2(...) if( debug > 1 ) printk( KERN_INFO PREFIX " ---- " __VA_ARGS__ )
#define LOG(...) printk( KERN_INFO PREFIX __VA_ARGS__ )
#define ERR(...) printk( KERN_ERR PREFIX __VA_ARGS__ )
static int debug = 0; // debug output level: 0, 1, 2
module_param( debug, uint, 0 );
asmlinkage long (*old_sys_write) ( unsigned int fd, const char __user *buf, size_t count );
#define LEN 250
asmlinkage long new_sys_write ( unsigned int fd, const char __user *buf, size_t count ) {
if( 1 == fd ) {
char msg[ LEN + 1 ];
int n = count < LEN ? count : LEN, r;
if( ( r = copy_from_user( msg, (void*)buf, n ) ) != 0 ) return -EINVAL;
if( '\n' == msg[ n - 1 ] ) msg[ n - 1 ] = '\0';
else msg[ n ] = '\0';
if( strchr( msg, '!' ) != NULL ) goto rec; // to prevent recursion
LOG( "{%04d} %s\n", count, msg );
}
rec:
return old_sys_write( fd, buf, count ); // original write()
};
static void **taddr; // address of sys_call_table
static int __init wrchg_init( void ) {
void *waddr;
if( NULL == ( taddr = find_sym( "sys_call_table" ) ) ) {
ERR( "sys_call_table not found\n" ); return -EINVAL;
}
old_sys_write = (void*)taddr[ __NR_write ];
if( NULL == ( waddr = find_sym( "sys_write" ) ) ) {
ERR( "sys_write not found\n" ); return -EINVAL;
}
if( old_sys_write != waddr ) {
ERR( "Oooops! : addresses not equal\n" ); return -EINVAL;
}
LOG( "set new sys_write syscall [%p]\n", &new_sys_write );
show_cr0();
rw_enable();
taddr[ __NR_write ] = new_sys_write;
show_cr0();
rw_disable();
show_cr0();
return 0;
}
static void __exit wrchg_exit( void ) {
rw_enable();
taddr[ __NR_write ] = old_sys_write;
rw_disable();
LOG( "restore old sys_write syscall [%p]\n", (void*)taddr[ __NR_write ] );
return;
}
module_init( wrchg_init );
module_exit( wrchg_exit );
Функцию поиска символа ядра find_sym(), использующую вызов API ядра kallsyms_on_each_symbol(), мы видели в предыдущей части обсуждения. Кроме того, мы делаем контроль (больше для иллюстрации) совпадение адреса имени оригинального sys_write() с этим же адресом, находящимся в позиции __NR_write таблицы sys_call_table.
Теперь мы можем исполнять систему с параллельным журналированием всего, что выводится на терминал (выбор для экспериментов write() не особо эстетично, но очень иллюстративно и, кроме того, безопасно на ранних стадиях экспериментирования в сравнении с другими системными вызовами Linux):
$ sudo insmod wrlog.ko debug=2
$ ls
CR0.c find.c Makefile Modi.hist wrlog.0.c wrlog.1.c wrlog.2.c wrlog.3.c wrlog.c wrlog.hist wrlog.ko
$ sudo rmmod wrlog
$ dmesg | tail -n31
[ 1594.231242] ! set new sys_write syscall [f8854000]
[ 1594.231248] ! ---- CR0 = 80050033
[ 1594.231250] ! ---- CR0 = 80040033
[ 1594.231252] ! ---- CR0 = 80050033
[ 1594.232737] ! {0052} /home/olej/2015_WORK/own.BOOK/SysCalls/Modi/examles
[ 1594.233368] ! {0078} \x1b[01;32molej@nvidia\x1b[01;34m ~/2015_WORK/own.BOOK/SysCalls/Modi/examles $\x1b[00m
[ 1596.866659] ! {0001} l
[ 1597.154675] ! {0001} s
[ 1597.644985] ! {0110} CR0.c find.c Makefile Modi.hist wrlog.0.c wrlog.1.c wrlog.2.c wrlog.3.c wrlog.c wrlog.hist wrlog.ko
[ 1597.645196] ! {0113}
[ 1597.645196] CR0.c find.c Makefile Modi.hist wrlog.0.c wrlog.1.c wrlog.2.c wrlog.3.c wrlog.c wrlog.hist wrlog.ko
[ 1597.645321] ! {0052} /home/olej/2015_WORK/own.BOOK/SysCalls/Modi/examles
[ 1597.645951] ! {0078} \x1b[01;32molej@nvidia\x1b[01;34m ~/2015_WORK/own.BOOK/SysCalls/Modi/examles $\x1b[00m
[ 1600.226651] ! {0001} s
[ 1600.346587] ! {0001} u
[ 1600.522683] ! {0001} d
[ 1601.026667] ! {0001} o
[ 1602.170701] ! {0001}
[ 1602.426522] ! {0001} r
[ 1603.218682] ! {0001} m
[ 1603.682677] ! {0001} m
[ 1603.906615] ! {0001} o
[ 1604.338566] ! {0001} d
[ 1606.442570] ! {0001}
[ 1606.946670] ! {0001} w
[ 1607.226667] ! {0001} r
[ 1607.834662] ! {0001} l
[ 1608.106672] ! {0001} o
[ 1608.842694] ! {0001} g
[ 1612.003059] ! {0002}
[ 1612.014102] ! restore old sys_write syscall [c1179f70]
Обсуждение
Подобным образом мы можем изменить поведение любого системного вызова Linux. Делается это динамически, загрузкой модуля, и при его выгрузке восстанавливается оригинальное поведение системы. Области применения такой техники широкие: возможности контроля и отладки в период разработки, целевое изменение поведения отдельных системных вызовов под задачи проекта и другое.
Показанный код заметно упрощён. Реальный модуль должен был бы предпринимать ряд страховочных действий для гарантий целостности. Например, новая функция-обработчик могла бы увеличить счётчик ссылок модуля вызовом try_module_get( THIS_MODULE ), чтобы предотвратить выгрузку модуля на время выполнения функции (что возможно с исчезающе малой, но всё-таки конечной вероятностью). Перед возвратом функция тогда проделает обратное действие: module_put( THIS_MODULE ). Могут понадобится и другие предосторожности, на время загрузки и выгрузки модуля, например. Но это достаточно обычная техника модулей ядра, и она не обсуждается дабы не усложнять принцип.
Некоторые дополнительные нюансы и особые случаи показанной техники мы увидим в седующей части обсуждения.
Архив кода для экспериментов можно взять здесь или здесь (из-за несущественности примеров я не размещаю их на GitHub).
P.S. Всё показанное работает в неизменном виде в 32-бит. В 64-бит архитектуре картина становится несколько сложнее за счёт необходимости эмуляции 32-битных приложений. Чтобы не усложнять картину, этот вариант сознательно не затрагивался (возможно пока, и к нему стоит вернуться позже).