NexxDigital - компьютеры и операционные системы

ВЛАДИМИР МЕШКОВ

Перехват системных вызовов в ОС Linux

За последние годы операционная система Linux прочно заняла лидирующее положение в качестве серверной платформы, опережая многие коммерческие разработки. Тем не менее вопросы защиты информационных систем, построенных на базе этой ОС, не перестают быть актуальными. Существует большое количество технических средств, как программных, так и аппаратных, которые позволяют обеспечить безопасность системы. Это средства шифрования данных и сетевого трафика, разграничения прав доступа к информационным ресурсам, защиты электронной почты, веб-серверов, антивирусной защиты, и т. д. Список, как вы понимаете, достаточно длинный. В данной статье предлагаем вам рассмотреть механизм защиты, основанный на перехвате системных вызовов операционной системы Linux. Данный механизм позволяет взять под контроль работу любого приложения и тем самым предотвратить возможные деструктивные действия, которые оно может выполнить.

Системные вызовы

Начнем с определения. Системные вызовы – это набор функций, реализованных в ядре ОС. Любой запрос приложения пользователя в конечном итоге трансформируется в системный вызов, который выполняет запрашиваемое действие. Полный перечень системных вызовов ОС Linux находится в файле /usr/include/asm/unistd.h. Давайте рассмотрим общий механизм выполнения системных вызовов на примере. Пусть в исходном тексте приложения вызывается функция creat() для создания нового файла. Компилятор, встретив вызов данной функции, преобразует его в ассемблерный код, обеспечивая загрузку номера системного вызова, соответствующего данной функции, и ее параметров в регистры процессора и последующий вызов прерывания 0x80. В регистры процессора загружаются следующие значения:

  • в регистр EAX – номер системного вызова. Так, для нашего случая номер системного вызова будет равен 8 (см. __NR_creat);
  • в регистр EBX – первый параметр функции (для creat это указатель на строку, содержащую имя создаваемого файла);
  • в регистр ECX – второй параметр (права доступа к файлу).

В регистр EDX загружается третий параметр, в данном случае он у нас отсутствует. Для выполнения системного вызова в ОС Linux используется функция system_call, которая определена в файле /usr/src/liux/arch/i386/kernel/entry.S. Эта функция – точка входа для всех системных вызовов. Ядро реагирует на прерывание 0x80 обращением к функции system_call, которая, по сути, представляет собой обработчик прерывания 0x80.

Чтобы убедиться, что мы на правильном пути, напишем небольшой тестовый фрагмент на ассемблере. В нем увидим, во что превращается функция creat() после компиляции. Файл назовем test.S. Вот его содержание:

Globl _start

Text

Start:

В регистр EAX загружаем номер системного вызова:

movl $8, %eax

В регистр EBX – первый параметр, указатель на строку с именем файла:

movl $filename, %ebx

В регистр ECX – второй параметр, права доступа:

movl $0, %ecx

Вызываем прерывание:

int $0x80

Выходим из программы. Для этого вызовем функцию exit(0):

movl $1, %eax movl $0, %ebx int $0x80

В сегменте данных укажем имя создаваемого файла:

Data

filename: .string "file.txt"

Компилируем:

gcc -с test.S

ld -s -o test test.o

В текущем каталоге появится исполняемый файл test. Запустив его, мы создадим новый файл с именем file.txt.

А теперь давайте вернемся к рассмотрению механизма системных вызовов. Итак, ядро вызывает обработчик прерывания 0x80 – функцию system_call. System_call помещает копии регистров, содержащих параметры вызова, в стек при помощи макроса SAVE_ALL и командой call вызывает нужную системную функцию. Таблица указателей на функции ядра, которые реализуют системные вызовы, расположена в массиве sys_call_table (см. файл arch/i386/kernel/entry.S). Номер системного вызова, который находится в регистре EAX, является индексом в этом массиве. Таким образом, если в EAX находится значение 8, будет вызвана функция ядра sys_creat(). Зачем нужен макрос SAVE_ALL? Объяснение тут очень простое. Так как практически все системные функции ядра написаны на С, то свои параметры они ищут в стеке. А параметры помещаются в стек при помощи макроса SAVE_ALL! Возвращаемое системным вызовом значение сохраняется в регистр EAX.

Теперь давайте выясним, как перехватить системный вызов. Поможет нам в этом механизм загружаемых модулей ядра. Хотя ранее мы уже рассматривали вопросы разработки и применения модулей ядра, в интересах последовательности изложения материала рассмотрим кратко, что такое модуль ядра, из чего он состоит и как взаимодействует с системой.

Загружаемый модуль ядра

Загружаемый модуль ядра (обозначим его LKM – Loadable Kernel Module) – это программный код, выполняемый в пространстве ядра. Главной особенностью LKM является возможность динамической загрузки и выгрузки без необходимости перезагрузки всей системы или перекомпиляции ядра.

Каждый LKM состоит из двух основных функций (минимум):

  • функция инициализации модуля. Вызывается при загрузке LKM в память:

int init_module(void) { ... }

  • функция выгрузки модуля:

void cleanup_module(void) { ... }

Приведем пример простейшего модуля:

#define MODULE

#include

int init_module(void)

printk("Hello World ");

return 0;

void cleanup_module(void)

printk("Bye ");

Компилируем и загружаем модуль. Загрузку модуля в память осуществляет команда insmod:

gcc -c -O3 helloworld.c

insmod helloworld.o

Информация обо всех загруженных в данный момент в систему модулях находится в файле /proc/modules. Чтобы убедиться, что модуль загружен, введите команду cat /proc/modules либо lsmod. Выгружает модуль команда rmmod:

rmmod helloworld

Алгоритм перехвата системного вызова

Для реализации модуля, перехватывающего системный вызов, необходимо определить алгоритм перехвата. Алгоритм следующий:

  • сохранить указатель на оригинальный (исходный) вызов для возможности его восстановления;
  • создать функцию, реализующую новый системный вызов;
  • в таблице системных вызовов sys_call_table произвести замену вызовов, т.е. настроить соответствующий указатель на новый системный вызов;
  • по окончании работы (при выгрузке модуля) восстановить оригинальный системный вызов, используя ранее сохраненный указатель.

Выяснить, какие системные вызовы задействуются при работе приложения пользователя, позволяет трассировка. Осуществив трассировку, можно определить, какой именно системный вызов следует перехватить, чтобы взять под контроль работу приложения. Пример использования программы трассировки будет рассмотрен ниже.

Теперь у нас достаточно информации, чтобы приступить к изучению примеров реализации модулей, осуществляющих перехват системных вызовов.

Примеры перехвата системных вызовов

Запрет создания каталогов

При создании каталога вызывается функция ядра sys_mkdir. В качестве параметра задается строка, в которой содержится имя создаваемого каталога. Рассмотрим код, осуществляющий перехват соответствующего системного вызова.

#include

#include

#include

Экспортируем таблицу системных вызовов:

extern void *sys_call_table;

Определим указатель для сохранения оригинального системного вызова:

int (*orig_mkdir)(const char *path);

Создадим собственный системный вызов. Наш вызов ничего не делает, просто возвращает нулевое значение:

int own_mkdir(const char *path)

return 0;

Во время инициализации модуля сохраняем указатель на оригинальный вызов и производим замену системного вызова:

int init_module()

orig_mkdir=sys_call_table;

sys_call_table=own_mkdir; return 0;

При выгрузке восстанавливаем оригинальный вызов:

void cleanup_module()

Sys_call_table=orig_mkdir;

Код сохраним в файле sys_mkdir_call.c. Для получения объектного модуля создадим Makefile следующего содержания:

CC = gcc

CFLAGS = -O3 -Wall -fomit-frame-pointer

sys_mkdir_call.o: sys_mkdir_call.c

$(CC) -c $(CFLAGS) $(MODFLAGS) sys_mkdir_call.c

Командой make создадим модуль ядра. Загрузив его, попытаемся создать каталог командой mkdir. Как вы можете убедиться, ничего при этом не происходит. Команда не работает. Для восстановления ее работоспособности достаточно выгрузить модуль.

Запрет чтения файла

Для того чтобы прочитать файл, его необходимо вначале открыть при помощи функции open. Легко догадаться, что этой функции соответствует системный вызов sys_open. Перехватив его, мы можем защитить файл от прочтения. Рассмотрим реализацию модуля-перехватчика.

#include

#include

#include

#include

#include

#include

#include

extern void *sys_call_table;

Указатель для сохранения оригинального системного вызова:

int (*orig_open)(const char *pathname, int flag, int mode);

Первым параметром функции open является имя открываемого файла. Новый системный вызов должен сравнить этот параметр с именем файла, который мы хотим защитить. Если имена совпадут, будет сымитирована ошибка открытия файла. Наш новый системный вызов имеет вид:

int own_open(const char *pathname, int flag, int mode)

Сюда поместим имя открываемого файла:

char *kernel_path;

Имя файла, который мы хотим защитить:

char hide="test.txt"

Выделим память и скопируем туда имя открываемого файла:

kernel_path=(char *)kmalloc(255,GFP_KERNEL);

copy_from_user(kernel_path, pathname, 255);

Сравниваем:

if(strstr(kernel_path,(char *)&hide) != NULL) {

Освобождаем память и возвращаем код ошибки при совпадении имен:

kfree(kernel_path);

return -ENOENT;

else {

Если имена не совпали, вызываем оригинальный системный вызов для выполнения стандартной процедуры открытия файла:

kfree(kernel_path);

return orig_open(pathname, flag, mode);

int init_module()

orig_open=sys_call_table;

sys_call_table=own_open;

return 0;

void cleanup_module()

sys_call_table=orig_open;

Сохраним код в файле sys_open_call.c и создадим Makefile для получения объектного модуля:

CC = gcc

CFLAGS = -O2 -Wall -fomit-frame-pointer

MODFLAGS = -D__KERNEL__ -DMODULE -I/usr/src/linux/include

sys_open_call.o: sys_open_call.c

$(CC) -c $(CFLAGS) $(MODFLAGS) sys_open_call.c

В текущем каталоге создадим файл с именем test.txt, загрузим модуль и введем команду cat test.txt. Система сообщит об отсутствии файла с таким именем.

Честно говоря, такую защиту легко обойти. Достаточно командой mv переименовать файл, а затем прочесть его содержимое.

Сокрытие записи о файле в каталоге

Определим, какой системный вызов отвечает за чтение содержимого каталога. Для этого напишем еще один тестовый фрагмент, который занимается чтением текущей директории:

/* Файл dir.c*/

#include

#include

int main ()

DIR *d;

struct dirent *dp;

d = opendir(«.»);

dp = readdir(d);

Return 0;

Получим исполняемый модуль:

gcc -o dir dir.c

и выполним его трассировку:

strace ./dir

Обратим внимание на предпоследнюю строку:

getdents (6, /* 4 entries*/, 3933) = 72;

Содержимое каталога считывает функция getdents. Результат сохраняется в виде списка структур типа struct dirent. Второй параметр этой функции является указателем на этот список. Функция возвращает длину всех записей в каталоге. В нашем примере функция getdents определила наличие в текущем каталоге четырех записей – «.», «..» и два наших файла, исполняемый модуль и исходный текст. Длина всех записей в каталоге составляет 72 байта. Информация о каждой записи сохраняется, как мы уже сказали, в структуре struct dirent. Для нас интерес представляют два поля данной структуры:

  • d_reclen – размер записи;
  • d_name – имя файла.

Для того чтобы спрятать запись о файле (другими словами, сделать его невидимым), необходимо перехватить системный вызов sys_getdents, найти в списке полученных структур соответствующую запись и удалить ее. Рассмотрим код, выполняющий эту операцию (автор оригинального кода – Michal Zalewski):

extern void *sys_call_table;

int (*orig_getdents)(u_int, struct dirent *, u_int);

Определим свой системный вызов.

int own_getdents(u_int fd, struct dirent *dirp, u_int count)

unsigned int tmp, n;

int t;

Назначение переменных будет показано ниже. Дополнительно нам понадобятся структуры:

struct dirent *dirp2, *dirp3;

Имя файла, который мы хотим спрятать:

char hide=»our.file»;

Определим длину записей в каталоге:

tmp=(*orig_getdents)(fd,dirp,count);

if(tmp>0){

Выделим память для структуры в пространстве ядра и скопируем в нее содержимое каталога:

dirp2=(struct dirent *)kmalloc(tmp,GFP_KERNEL);

сopy_from_user(dirp2,dirp,tmp);

Задействуем вторую структуру и сохраним значение длины записей в каталоге:

dirp3=dirp2;

t=tmp;

Начнем искать наш файл:

while(t>0) {

Считываем длину первой записи и определяем оставшуюся длину записей в каталоге:

n=dirp3->d_reclen;

t-=n;

Проверяем, не совпало ли имя файла из текущей записи с искомым:

if(strstr((char *)&(dirp3->d_name),(char *)&hide) != NULL) {

Если это так, затираем запись и вычисляем новое значение длины записей в каталоге:

memcpy(dirp3,(char *)dirp3+dirp3->d_reclen,t);

tmp-=n;

Позиционируем указатель на следующую запись и продолжаем поиск:

dirp3=(struct dirent *)((char *)dirp3+dirp3->d_reclen);

Возвращаем результат и освобождаем память:

copy_to_user(dirp,dirp2,tmp);

kfree(dirp2);

Возвращаем значение длины записей в каталоге:

return tmp;

Функции инициализации и выгрузки модуля имеют стандартный вид:

int init_module(void)

orig_getdents=sys_call_table;

sys_call_table=own_getdents;

return 0;

void cleanup_module()

sys_call_table=orig_getdents;

Сохраним исходный текст в файле sys_call_getd.c и создадим Makefile следующего содержания:

CC = gcc

module = sys_call_getd.o

CFLAGS = -O3 -Wall

LINUX = /usr/src/linux

MODFLAGS = -D__KERNEL__ -DMODULE -I$(LINUX)/include

sys_call_getd.o: sys_call_getd.c $(CC) -c

$(CFLAGS) $(MODFLAGS) sys_call_getd.c

В текущем каталоге создадим файл our.file и загрузим модуль. Файл исчезает, что и требовалось доказать.

Как вы понимаете, рассмотреть в рамках одной статьи пример перехвата каждого системного вызова не представляется возможным. Поэтому тем, кто заинтересовался данным вопросом, рекомендую посетить сайты:

Там вы сможете найти более сложные и интересные примеры перехвата системных вызовов. Обо всех замечаниях и предложениях пишите на форум журнала.

При подготовке статьи были использованы материалы сайта


Системные вызовы

Пока что все программы, которые мы сделали должны были использовать хорошо определенные механизмы ядра, чтобы регистрировать /proc файлы и драйверы устройства. Это прекрасно, если Вы хотите делать что-то уже предусмотренное программистами ядра, например писать драйвер устройства. Но что, если Вы хотите сделать что-то необычное, изменить поведение системы некоторым способом?

Это как раз то место, где программирование ядра становится опасным. При написании примера ниже, я уничтожил системный вызов open . Это подразумевало, что я не могу открывать любые файлы, я не могу выполнять любые программы, и я не могу закрыть систему командой shutdown . Я должен выключить питание, чтобы ее остановить. К счастью, никакие файлы не были уничтожены. Чтобы гарантировать, что Вы также не будете терять файлы, пожалуйста выполните sync прежде чем Вы отдадите команды insmod и rmmod .

Забудьте про /proc файлы и файлы устройств. Они только малые детали. Реальный процесс связи с ядром, используемый всеми процессами, это системные вызовы. Когда процесс запрашивает обслуживание из ядра (типа открытия файла, запуска нового процесса или запроса большего количества памяти), используется этот механизм. Если Вы хотите изменить поведение ядра интересными способами, это как раз подходящее место. Между прочим, если Вы хотите видеть какие системные вызовы использованы программой, выполните: strace .

Вообще, процесс не способен обратиться к ядру. Он не может обращаться к памяти ядра и не может вызывать функции ядра. Аппаратные средства CPU предписывают такое положение дел (недаром это называется `protected mode" (защищенный режим)). Системные вызовы исключение из этого общего правила. Процесс заполняет регистры соответствующими значениями и затем вызывает специальную команду, которая переходит к предварительно определенному месту в ядре (конечно, оно читается процессами пользователя, но не перезаписывается ими). Под Intel CPUs, это выполнено посредством прерывания 0x80. Аппаратные средства знают, что, как только Вы переходите к этому месту, Вы больше не работаете в ограниченном режиме пользователя. Вместо этого Вы работаете как ядро операционной системы, и следовательно вам позволено делать все, что Вы хотите сделать.

Место в ядре, к которому процесс может переходить, названо system_call . Процедура, которая там находится, проверяет номер системного вызова, который сообщает ядру чего именно хочет процесс. Затем, она просматривает таблицу системных вызовов (sys_call_table), чтобы найти адрес функции ядра, которую надо вызвать. Затем вызывается нужная функция, и после того, как она возвращает значение, делается несколько проверок системы. Затем результат возвращается обратно процессу (или другому процессу, если процесс завершился). Если Вы хотите посмотреть код, который все это делает, он находится в исходном файле arch/ < architecture > /kernel/entry.S , после строки ENTRY(system_call) .

Так, если мы хотим изменить работу некоторого системного вызова, то первое, что мы должны сделать, это написать нашу собственную функцию, чтобы она выполняла соответствующие действия (обычно, добавляя немного нашего собственного кода, и затем вызывая первоначальную функцию), затем изменить указатель в sys_call_table , чтобы указать на нашу функцию. Поскольку мы можем быть удалены позже и не хотим оставлять систему в непостоянном состоянии, это важно для cleanup_module , чтобы восстановить таблицу в ее первоначальном состоянии.

Исходный текст, приводимый здесь, является примером такого модуля. Мы хотим "шпионить" за некоторым пользователем, и посылать через printk сообщение всякий раз, когда данный пользователь открывает файл. Мы заменяем системный вызов, открытия файла нашей собственной функцией, названной our_sys_open . Эта функция проверяет uid (user id) текущего процесса, и если он равен uid, за которым мы шпионим, вызывает printk , чтобы отобразить имя файла, который будет открыт. Затем вызывает оригинал функции open с теми же самыми параметрами, фактически открывает файл.

Функция init_module меняет соответствующее место в sys_call_table и сохраняет первоначальный указатель в переменной. Функция cleanup_module использует эту переменную, чтобы восстановить все назад к норме. Этот подход опасен, из-за возможности существования двух модулей, меняющих один и тот же системный вызов. Вообразите, что мы имеем два модуля, А и B. Системный вызов open модуля А назовем A_open и такой же вызов модуля B назовем B_open. Теперь, когда вставленный в ядро системный вызов заменен на A_open, который вызовет оригинал sys_open, когда сделает все, что ему нужно. Затем, B будет вставлен в ядро, и заменит системный вызов на B_open, который вызовет то, что как он думает, является первоначальным системным вызовом, а на самом деле является A_open.

Теперь, если B удален первым, все будет хорошо: это просто восстановит системный вызов на A_open, который вызывает оригинал. Однако, если удален А, и затем удален B, система разрушится. Удаление А восстановит системный вызов к оригиналу, sys_open, вырезая B из цикла. Затем, когда B удален, он восстановит системный вызов к тому, что он считает оригиналом, На самом деле вызов будет направлен на A_open, который больше не в памяти. На первый взгляд кажется, что мы могли бы решать эту специфическую проблему, проверяя, если системный вызов равен нашей функции open и если так, не менять значение этого вызова (так, чтобы B не изменил системный вызов, когда удаляется), но это вызовет еще худшую проблему. Когда А удаляется, он видит, что системный вызов был изменен на B_open так, чтобы он больше не указывал на A_open, так что он не будет восстанавливать указатель на sys_open прежде, чем будет удалено из памяти. К сожалению, B_open будет все еще пробовать вызывать A_open, который больше не в памяти, так что даже без удаления B система все равно рухнет.

Я вижу два способа предотвратить эту проблему. Первое: восстановить обращение к первоначальному значению sys_open. К сожалению, sys_open не является частью таблицы ядра системы в /proc/ksyms , так что мы не можем обращаться к нему. Другое решение состоит в том, чтобы использовать счетчик ссылки, чтобы предотвратить выгрузку модуля. Это хорошо для обычных модулей, но плохо для "образовательных" модулей.

/* syscall.c * * System call "stealing" sample */ /* Copyright (C) 1998-99 by Ori Pomerantz */ /* The necessary header files */ /* Standard in kernel modules */ #include /* We"re doing kernel work */ #include /* Specifically, a module */ /* Deal with CONFIG_MODVERSIONS */ #if CONFIG_MODVERSIONS==1 #define MODVERSIONS #include #endif #include /* The list of system calls */ /* For the current (process) structure, we need * this to know who the current user is. */ #include /* In 2.2.3 /usr/include/linux/version.h includes a * macro for this, but 2.0.35 doesn"t - so I add it * here if necessary. */ #ifndef KERNEL_VERSION #define KERNEL_VERSION(a,b,c) ((a)*65536+(b)*256+(c)) #endif #if LINUX_VERSION_CODE >= KERNEL_VERSION(2,2,0) #include #endif /* The system call table (a table of functions). We * just define this as external, and the kernel will * fill it up for us when we are insmod"ed */ extern void *sys_call_table; /* UID we want to spy on - will be filled from the * command line */ int uid; #if LINUX_VERSION_CODE >= KERNEL_VERSION(2,2,0) MODULE_PARM(uid, "i"); #endif /* A pointer to the original system call. The reason * we keep this, rather than call the original function * (sys_open), is because somebody else might have * replaced the system call before us. Note that this * is not 100% safe, because if another module * replaced sys_open before us, then when we"re inserted * we"ll call the function in that module - and it * might be removed before we are. * * Another reason for this is that we can"t get sys_open. * It"s a static variable, so it is not exported. */ asmlinkage int (*original_call)(const char *, int, int); /* For some reason, in 2.2.3 current->uid gave me * zero, not the real user ID. I tried to find what went * wrong, but I couldn"t do it in a short time, and * I"m lazy - so I"ll just use the system call to get the * uid, the way a process would. * * For some reason, after I recompiled the kernel this * problem went away. */ asmlinkage int (*getuid_call)(); /* The function we"ll replace sys_open (the function * called when you call the open system call) with. To * find the exact prototype, with the number and type * of arguments, we find the original function first * (it"s at fs/open.c). * * In theory, this means that we"re tied to the * current version of the kernel. In practice, the * system calls almost never change (it would wreck havoc * and require programs to be recompiled, since the system * calls are the interface between the kernel and the * processes). */ asmlinkage int our_sys_open(const char *filename, int flags, int mode) { int i = 0; char ch; /* Check if this is the user we"re spying on */ if (uid == getuid_call()) { /* getuid_call is the getuid system call, * which gives the uid of the user who * ran the process which called the system * call we got */ /* Report the file, if relevant */ printk("Opened file by %d: ", uid); do { #if LINUX_VERSION_CODE >= KERNEL_VERSION(2,2,0) get_user(ch, filename+i); #else ch = get_user(filename+i); #endif i++; printk("%c", ch); } while (ch != 0); printk("\n"); } /* Call the original sys_open - otherwise, we lose * the ability to open files */ return original_call(filename, flags, mode); } /* Initialize the module - replace the system call */ int init_module() { /* Warning - too late for it now, but maybe for * next time... */ printk("I"m dangerous. I hope you did a "); printk("sync before you insmod"ed me.\n"); printk("My counterpart, cleanup_module(), is even"); printk("more dangerous. If\n"); printk("you value your file system, it will "); printk("be \"sync; rmmod\" \n"); printk("when you remove this module.\n"); /* Keep a pointer to the original function in * original_call, and then replace the system call * in the system call table with our_sys_open */ original_call = sys_call_table[__NR_open]; sys_call_table[__NR_open] = our_sys_open; /* To get the address of the function for system * call foo, go to sys_call_table[__NR_foo]. */ printk("Spying on UID:%d\n", uid); /* Get the system call for getuid */ getuid_call = sys_call_table[__NR_getuid]; return 0; } /* Cleanup - unregister the appropriate file from /proc */ void cleanup_module() { /* Return the system call back to normal */ if (sys_call_table[__NR_open] != our_sys_open) { printk("Somebody else also played with the "); printk("open system call\n"); printk("The system may be left in "); printk("an unstable state.\n"); } sys_call_table[__NR_open] = original_call; }

Данный материал является модификацией одноименной статьи Владимира Мешкова, опубликованной в журнале "Системный администратор"

Данный матрериал являеться копиями статей Владимира Мешкова с журанала "Системный администратор". Данные статьи могут быть найдены по приведенным ниже ссылкам. Так же были изменены некоторые примеры исходных текстов программ - улучшены, доработаны. (Сильно изменен пример 4.2, так как пришлось перехватывать немного другой системный вызов) URLs: http://www.samag.ru/img/uploaded/p.pdf http://www.samag.ru/img/uploaded/a3.pdf

Есть вопросы? Тогда вам сюда: [email protected]

  • 2. Загружаемый модуль ядра
  • 4. Примеры перехвата системных вызовов на основе LKM
    • 4.1 Запрет создания каталогов

1. Общий взгяд на архитектуру Linux

Самый общий взгяд позволяет увидеть двухуровневую модель системы. kernel <=> progs В центре (слева) находиться ядро системы. Ядро непосредственно взаимодействует, с аппаратной частью компьютера, изолируя прикладные программы от особенностей архитектуры. Ядро имеет набор услуг предоставляемых прикладным программам. К услугам ядра относятся операции ввода/вывода (открытия, чтения, записи и управление файлами), создание и управление процессами, их синхронизации и межпроцессного взаимодействия. Все приложения запрашивают услуги ядра посредством системных вызовов.

Второй уровень составляют приложения или задачи, как системные, определяющие функциональность системы, так и прикладные, обеспечивающие пользовательский интерфейс Linux. Однако не смотря на внешнюю разнородность приложений, схемы взаимодействия с ядром одинаковы.

Взаимодействие с ядром происходит посредством стандартного интерфейса системных вызовов. Интерфейс системных вызовов представляет собой набор услуг ядра и определяет формат запросов на услуги. Процесс запрашивает услугу посредством системного вызова определенной процедуры ядра, внешне похожего на обычный вызов библиотечной функции. Ядро от имени процесса выполняет запрос и возвращает процессу необходимые данные.

В приведенном примере программа открывает файл, считывает из него данные и закрывает этот файл. При этом операция открытия (open), чтения (read) и закрытия (close) файла выполняются ядром по запросу задачи, а функция open(2), read(2) и close(2) являются системными вызовами.

/* Source 1.0 */ #include main () { int fd; char buf; /* Откроем файл - получим ссылку (файловый дескриптор) fd */ fd = open("file1",O_RDONLY); /* Считаем в буфер buf 80 символов */ read(fd, buf, sizeof(buf)); /* Закроем файл */ close(fd); } /* EOF */ Полный перечень системных вызовов OS Linux находиться в файле /usr/include/asm/unistd.h. Давайте теперь рассмотрим механизм выполнения системных вызовов на данном примере. Компилятор, встретив функцию open() для открытия файла, преобразует его в ассемблерный код, обеспечивая загрузку номера системного вызова, соответствующего данной функции, и ее параметров в регистры процессора и последующий вызов прерывания 0x80. В регистры процессора загружаются следующие значения:

  • в регистр EAX - номер системного вызова. Так, для нашего случая номер системного вызова равен 5 (см. __NR_open).
  • в регистр EBX - первый параметр функции (для open() - это указатель на строку, содержащую имя открываемого файла.
  • в регистр ECX - второй параметр (права доступа к файлу)
В регистр EDX загружается третий параметр, в данном случае он у нас отсутствует. Для выполнения системного вызова в OS Linux используется функция system_call, которая определена (в зависимости от архитектуры в данном случае i386) в файле /usr/src/linux/arch/i386/kernel/entry.S. Эта функция - точка входа для всех системных вызовов. Ядро реагирует на прерывание 0x80 обращением к функции system_call, которая, по сути, представляет собой обработчик прерывания 0x80.

Чтобы убедиться, что мы на правильном пути, рассмотрим код функции open() в системной библиотеке libc:

# gdb -q /lib/libc.so.6 (gdb) disas open Dump of assembler code for function open: 0x000c8080 : call 0x1082be < __i686.get_pc_thunk.cx > 0x000c8085 : add $0x6423b,%ecx 0x000c808b : cmpl $0x0,0x1a84(%ecx) 0x000c8092 : jne 0xc80b1 0x000c8094 : push %ebx 0x000c8095 : mov 0x10(%esp,1),%edx 0x000c8099 : mov 0xc(%esp,1),%ecx 0x000c809d : mov 0x8(%esp,1),%ebx 0x000c80a1 : mov $0x5,%eax 0x000c80a6 : int $0x80 ... Как не трудно заметить в последних строках, происходит передача параметров в регистры EDX, ECX, EBX, а в последний регистр EAX кладeтся номер системного вызова равный как мы уже знаем 5.

А теперь давайте вернемся к рассмотрению механизма системных вызовов. Итак, ядро вызывает обработчик прерывания 0x80 - функцию system_call. System_call помещает копии регистров, содержащих параметры вызова в стэк, при помощи макроса SAVE_ALL и командой call вызывает нужную системную функцию. Таблица указателей на функции ядра, которые реализуют системные вызовы, расположена в массиве sys_call_table (см. файл arch/i386/kernel/entry.S). Номер системного вызова, который находиться в регистре EAX, является индексом в этом массиве. Таким образом, если в EAX находиться значение 5, будет вызвана функция ядра sys_open(). Зачем нужен макрос SAVE_ALL? Объяснение тут очень простое. Так как практически все системные функции ядра написаны на C, то свои параметры они ищут в стеке. А параметры помещаются в стек при помощи SAVE_ALL! Возвращаемое системным вызовом значение сохраняется в регистр EAX.

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

2. Загружаемый модуль ядра

Загружаемый модуль ядра (общепринятое сокращение LKM - Loadable Kernel Module) - программный код, выполняемый в пространстве ядра. Главной особенностью LKM является возможность динамической загрузки и выгрузки без необходимости перезагрузки всей системы или перекомпиляции ядра.

Каждый LKM состоит из двух основных функций (минимум):

  • функция инициализации модуля. Вызывается при загрузке LKM в память: int init_module(void) { ... }
  • функция выгрузки модуля: void cleanup_module(void) { ... }
Приведем пример простейшего модуля: /* Source 2.0 */ #include int init_module(void) { printk("Hello World\n"); return 0; } void cleanup_module(void) { printk("Bye\n"); } /* EOF */ Компилируем и загружаем модуль. Загрузка модуля в память осуществляется командой insmod, а просмотр загруженных модулей командой lsmod: # gcc -c -DMODULE -I/usr/src/linux/include/ src-2.0.c # insmod src-2.0.o Warning: loading src-2.0.o will taint the kernel: no license Module src-2.0 loaded, with warnings # dmesg | tail -n 1 Hello World # lsmod | grep src src-2.0 336 0 (unused) # rmmod src-2.0 # dmesg | tail -n 1 Bye

3. Алгоритм перехвата системного вызова на основе LKM

Для реализации модуля, перехватывающего системный вызов, необходимо определить алгоритм перехвата. Алгоритм следующий:
  • сохранить указатель на оригинальный (исходный) вызов для возможности его восстановления
  • создать функцию, реализующую новый системный вызов
  • в таблице системных вызовов sys_call_table произвести замену вызовов, т.е настроить соответствующий указатель на новый системный вызов
  • по окончании работы (при выгрузке модуля) восстановить оригинальный системный вызов, используя ранее сохраненный указатель
Выяснить, какие системные вызовы задействуются пр работе приложения пользователя, позволяет трассировка. Осуществив трассировку, можно определить, какой именно системный вызов следует перехватить, чтобы взять под контроль приложение. # ltrace -S ./src-1.0 ... open("file1", 0, 01 SYS_open("file1", 0, 01) = 3 <... open resumed>) = 3 read(3, SYS_read(3, "123\n", 80) = 4 <... read resumed> "123\n", 80) = 4 close(3 SYS_close(3) = 0 <... close resumed>) = 0 ... Теперь у нас достаточно информации, чтобы приступить к изучению примеров реализации модулей, осуществляющих перехват системных вызовов.

4. Примеры перехвата системных вызовов на основе LKM

4.1 Запрет создания каталогов

При создании каталога вызвывается функция ядра sys_mkdir. В качестве параметра задаеться строка, которой содержится имя создаваемого каталога. Рассмотрим код, осуществляющий перехват соответствующего системного вызова. /* Source 4.1 */ #include #include #include /* Экспортируем таблицу системных вызовов */ extern void *sys_call_table; /* Определим указатель для сохранения оригинально вызова */ int (*orig_mkdir)(const char *path); /* Создадим собственный системный вызов. Наш вызов ничего не делает, просто возвращает нулевое значение */ int own_mkdir(const char *path) { return 0; } /* Во время инициализации модуля сохраняем указатель на оригинальный вызов и производим замену системного вызова */ int init_module(void) { orig_mkdir=sys_call_table; sys_call_table=own_mkdir; printk("sys_mkdir replaced\n"); return(0); } /* При выгрузке восстанавливаем оригинальный вызов */ void cleanup_module(void) { sys_call_table=orig_mkdir; printk("sys_mkdir moved back\n"); } /* EOF */ Для получения объектного модуля, выполним следующую команду и проведем ряд экспериментов над системой: # gcc -c -DMODULE -I/usr/src/linux/include/ src-3.1.c # dmesg | tail -n 1 sys_mkdir replaced # mkdir test # ls -ald test ls: test: No such file or directory # rmmod src-3.1 # dmesg | tail -n 1 sys_mkdir moved back # mkdir test # ls -ald test drwxr-xr-x 2 root root 4096 2003-12-23 03:46 test Как вы можете убедиться команда "mkdir" не работает, а точнее ничего не происходит. Для восстановления работоспособности системы достаточно выгрузить модуль. Что и проделано выше.

4.2 Сокрытие записи о файле в каталоге

Определим, какой системный вызов отвечает за чтение содержимого каталога. Для этого напишем еще один тестовый фрагмент, который занимается чтение текущей директории: /* Source 4.2.1 */ #include #include int main() { DIR *d; struct dirent *dp; d = opendir("."); dp = readdir(d); return 0; } /* EOF */ Получим исполняемый файл и выполним трассировку: # gcc -o src-3.2.1 src-3.2.1.c # ltrace -S ./src-3.2.1 ... opendir("." SYS_open(".", 100352, 010005141300) = 3 SYS_fstat64(3, 0xbffff79c, 0x4014c2c0, 3, 0xbffff874) = 0 SYS_fcntl64(3, 2, 1, 1, 0x4014c2c0) = 0 SYS_brk(NULL) = 0x080495f4 SYS_brk(0x0806a5f4) = 0x0806a5f4 SYS_brk(NULL) = 0x0806a5f4 SYS_brk(0x0806b000) = 0x0806b000 <... opendir resumed>) = 0x08049648 readdir(0x08049648 SYS_getdents64(3, 0x08049678, 4096, 0x40014400, 0x4014c2c0) = 528 <... readdir resumed>) = 0x08049678 ... Следует обратить внимание на последнюю строку. Содержимое каталога считывается функцией getdents64 (в других ядрах возможна getdents). Результат сохраянется в виде списка структур типа struct dirent, а сама функция возвращает длину всех записей в каталоге. Для нас интересны два поля этой структуры:
  • d_reclen - размер записи
  • d_name - имя файла
Для того, чтобы спрятать файл запись о файле (другими словами, сделать его невидимым), необходимо перехватить системный вызов sys_getdents64, найти в списке полученных структур соответствующую запись и удалить ее. Рассмотрим код, выполняющий эту операцию (автор оригинального кода - Michal Zalewski): /* Source 4.2.2 */ #include #include #include #include #include #include #include #include extern void *sys_call_table; int (*orig_getdents)(u_int fd, struct dirent *dirp, u_int count); /* Определим свой системный вызов */ int own_getdents(u_int fd, struct dirent *dirp, u_int count) { unsigned int tmp, n; int t; struct dirent64 { int d_ino1,d_ino2; int d_off1,d_off2; unsigned short d_reclen; unsigned char d_type; char d_name; } *dirp2, *dirp3; /* Имя файла, который мы хотим спрятать */ char hide = "file1"; /* Определим длину записей в каталоге */ tmp = (*orig_getdents)(fd,dirp,count); if (tmp>0) { /* Выделим память для структуры в пространстве ядра и скопируем в нее содержимое каталога */ dirp2 = (struct dirent64 *)kmalloc(tmp,GFP_KERNEL); copy_from_user(dirp2,dirp,tmp); /* Задействуем вторую структуру и сохраним значение длины записей в каталоге */ dirp3 = dirp2; t = tmp; /* Начнем искать наш файл */ while (t>0) { /* Считываем длину первой записи и определяем оставшуюся длину записей в каталоге */ n = dirp3->d_reclen; t -= n; /* Проверяем, не совпало ли имя файла из текущей записи с искомым */ if (strstr((char *)&(dirp3->d_name), (char *)&hide) != NULL) { /* Если это так, то затираем запись и вычисляем новое значение длины записей в каталоге */ memcpy(dirp3, (char *)dirp3+dirp3->d_reclen, t); tmp -= n; } /* Позиционируем указатель на следующую запись и продолжаем поиск */ dirp3 = (struct dirent64 *)((char *)dirp3+dirp3->d_reclen); } /* Возвращаем результат и освобождаем память */ copy_to_user(dirp,dirp2,tmp); kfree(dirp2); } /* Возвращаем значение длины записей в каталоге */ return tmp; } /* Функции инициализации и выгрузки модуля имеют стандартный вид */ int init_module(void) { orig_getdents = sys_call_table; sys_call_table=own_getdents; return 0; } void cleanup_module() { sys_call_table=orig_getdents; } /* EOF */ Скомпилировав данный код, заметим как исчезает "file1", что и требовалось доказать.

5. Метод прямого доступа к адресному пространству ядра /dev/kmem

Рассмотрим сначала теоретически, как осуществляется перехват методом прямого доступа к адресному пространству ядра, а затем приступим к практической реализации.

Прямой доступ к адресному пространству ядра обеспечивает файл устройства /dev/kmem. В этом файле отображено все доступное виртуальное адресное пространство, включая раздел подкачки (swap-область). Для работы с файлом kmem используются стандартные системные функции - open(), read(), write(). Открыв стандартным способом /dev/kmem, мы можем обратиться к любому адресу в системе, задав его как смещение в этом файле. Данный метод был разработан Silvio Cesare.

Обращение к системным функциям осуществляется посредством загрузки параметров функции в регистры процессора и последующим вызовом программного прерывания 0x80. Обработчик этого прерывания, функция system_call, помещает параметры вызова в стэк, извлекает из таблицы sys_call_table адрес вызываемой системной функции и передает управление по этому адресу.

Имея полный доступ к адресному пространству ядра, мы можем получить все содержимое таблицы системных вызовов, т.е. адреса всех системных функций. Изменив адрес любого системного вызова, мы, тем самым, осуществим его перехват. Но для этого необходимо знать адрес таблицы, или, другими словами, смещение в файле /dev/kmem, по которому эта таблица расположена.

Чтобы определить адрес таблицы sys_call_table, предварительно необходимо вычислить адрес функции system_call. Поскольку данная функция является обработчиком прерывания, давайте рассмотрим, как обрабатываются прерывания в защищенном режиме.

В реальном режиме процессор при регистрации прерывания обращается таблице векторов прерываний, находящейся всегда в самом начале памяти и содержащей двусловные адреса программ обработки прерываний. В защищенном режиме аналогом таблице векторов прерываний является таблица дескрипторов прерываний (IDT, Interrupt Descriptor Table), располагающаяся в операционной системе защищенного режима. Для того, чтобы процессор мог обратиться к этой таблице, ее адрес следует загрузить в регистр IDTR (Interrupt Descriptor Table Register, регистр таблицы дескрипторов прерываний). Таблица IDT содержит дескрипторы обработчиков прерываний, в которые, в частности, входят их адреса. Эти дескрипторы называются шлюзами (вентилями). Процессор, зарегистрировав прерывание, по его номеру извлекает из IDT шлюз, определяет адрес обработчика и передает ему управление.

Для вычисления адреса функции system_call из таблицы IDT необходимо извлечь шлюз прерывания int $0x80, а из него - адрес соответствующего обработчика, т.е. адрес функции system_call. В функции system_call обращение к таблице system_call_table выполняется командой call <адрес_таблицы>(,%eax,4). Найдя опкод (сигнатуру) этой команды в файле /dev/kmem, мы найдем и адрес таблицы системных вызовов.

Для определения опкода воспользуемся отладчиком и дизассемблируем функцию system_call:

# gdb -q /usr/src/linux/vmlinux (gdb) disas system_call Dump of assembler code for function system_call: 0xc0194cbc : push %eax 0xc0194cbd : cld 0xc0194cbe : push %es 0xc0194cbf : push %ds 0xc0194cc0 : push %eax 0xc0194cc1 : push %ebp 0xc0194cc2 : push %edi 0xc0194cc3 : push %esi 0xc0194cc4 : push %edx 0xc0194cc5 : push %ecx 0xc0194cc6 : push %ebx 0xc0194cc7 : mov $0x18,%edx 0xc0194ccc : mov %edx,%ds 0xc0194cce : mov %edx,%es 0xc0194cd0 : mov $0xffffe000,%ebx 0xc0194cd5 : and %esp,%ebx 0xc0194cd7 : testb $0x2,0x18(%ebx) 0xc0194cdb : jne 0xc0194d3c 0xc0194cdd : cmp $0x10e,%eax 0xc0194ce2 : jae 0xc0194d69 0xc0194ce8 : call *0xc02cbb0c(,%eax,4) 0xc0194cef : mov %eax,0x18(%esp,1) 0xc0194cf3 : nop End of assembler dump. Строка "call *0xc02cbb0c(,%eax,4)" и есть обращение к таблице sys_call_table. Значение 0xc02cbb0c - адрес таблицы (скорее всего у вас числа будут другими). Получим опкод этой команды: (gdb) x/xw system_call+44 0xc0194ce8 : 0x0c8514ff Мы нашли опкод команды обращения к таблице sys_call_table. Он равен \xff\x14\x85. Следующие за ним 4 байта - это адрес таблицы. Убедиться в этом можно введя команду: (gdb) x/xw system_call+44+3 0xc0194ceb : 0xc02cbb0c Таким образом, найдя в файле /dev/kmem последовательность \xff\x14\x85 и считав следующие за ней 4 байта, мы получим адрес таблицы системных вызовов sys_call_table. Зная ее адрес, мы можем получить содержимое этой таблицы (адреса всех системных функций) и изменить адрес любого системного вызова, перехватив его.

Рассмотрим псевдокод, выполняющий операцию перехвата:

Readaddr (old_syscall, scr + SYS_CALL*4, 4); writeaddr (new_syscall, scr + SYS_CALL*4, 4); Функция readaddr считывает адрес системного вызова из таблицы системных вызовов и сохраняет его в переменной old_syscall. Каждая запись в таблице sys_call_table занимает 4 байта. Искомый адрес расположен по смещению sct + SYS_CALL*4 в вайле /dev/kmem (здесь sct - адрес таблицы sys_call_table, SYS_CALL - порядковый номер системного вызова). Функция writeaddr перезаписывает адрес системного вызова SYS_CALL адресом функции new_syscall, и все обращения к системного вызову SYS_CALL будут обслуживаться этой функцией.

Кажеться, все просто и цель достигнута. Однако давайте вспомним, что мы работает в адресном пространстве пользователя. Если разместить новую системную функцию в этом адресном пространстве, то при вызове этой функции мы получим красивое сообщение об ошибке. Отсюда вывод - новый системный вызов необходимо разместить в адресном пространстве ядра. Для этого необходимо: получить блок памяти в пространстве ядра, разместить в этом блоке новый системный вызов.

Выделить память в пространстве ядра можно при помощи функции kmalloc. Но вызвать напрямую функцию ядра из адресного пространства пользователя нельзя, поэтому воспользуемся следующим алгоритмом:

  • зная адрес таблицы sys_call_table, получаем адрес некоторого системного вызова (например, sys_mkdir)
  • определяем функцию, выполняющую обращение к функции kmalloc. Эта функция возвращает указатель на блок памяти в адресном пространстве ядра. Назовем эту функцию get_kmalloc
  • сохраняем первые N байт системного вызова sys_mkdir, где N - размер функции get_kmalloc
  • перезаписываем первые N байт вызова sys_mkdir функцией get_kmalloc
  • выполняем обращение к системному вызову sys_mkdir, тем самым запустив на выполнение функцию get_kmalloc
  • восстанавливаем первые N байт системного вызова sys_mkdir
В результате в нашем распоряжении будет блок памяти, расположенный в пространстве ядра.

Но для реализации данного алгоритма нам необходим адрес функции kmalloc. Найти его можно несколькими способами. Самый простой - это считать этот адрес из файла System.map или определить с помошью отладчика gdb (print &kmalloc). Если в ядре включена поддержка модулей, адрес kmalloc можно определить при помощи функции get_kernel_syms(). Этот вариант будет рассмотрен далее. Если же поддержка модулей ядра отсутствует, то адрес функции kmalloc придеться искать по опкоду команды вызова kmalloc - аналогично тому, как было сделано для таблицы sys_call_table.

Функция kmalloc принимает два параметра: размер запрашиваемой памяти и спецификатор GFP. Для поиска опкода воспользуемся отладчиком и дизассемблируем любую функцию ядра, в которой есть вызов функции kmalloc.

# gdb -q /usr/src/linux/vmlinux (gdb) disas inter_module_register Dump of assembler code for function inter_module_register: 0xc01a57b4 : push %ebp 0xc01a57b5 : push %edi 0xc01a57b6 : push %esi 0xc01a57b7 : push %ebx 0xc01a57b8 : sub $0x10,%esp 0xc01a57bb : mov 0x24(%esp,1),%ebx 0xc01a57bf : mov 0x28(%esp,1),%esi 0xc01a57c3 : mov 0x2c(%esp,1),%ebp 0xc01a57c7 : movl $0x1f0,0x4(%esp,1) 0xc01a57cf : movl $0x14,(%esp,1) 0xc01a57d6 : call 0xc01bea2a ... Неважно что делает функция, главное в ней есть то, что нам нужно - вызов функции kmalloc. Обращаем внимание на последнии строки. Сначала в стэк (регистр esp указывает на верхушку стэка) загружаются параметры, а затем следует вызов функции. Первым в стэк загружается спецификатор GFP ($0x1f0,0x4(%esp,1). Для версий ядра 2.4.9 и выше это значение составляет 0x1f0. Найдем опкод этой команды: (gdb) x/xw inter_module_register+19 0xc01a57c7 : 0x042444c7 Если мы найдем этот опкод, то сможем вычислить адрес функции kmalloc. На первый взгяд, адрес этой функции является аргументом инструкции call, но это не совсем так. В отличии от функции system_call, здесь за инструкцией стоит не адрес kmalloc, а смещение к нему относительно текущего адреса. Убедимся в этом, определив опкод команды call 0xc01bea2a: (gdb) x/xw inter_module_register+34 0xc01a57d6 : 0x01924fe8 Первый байт равен e8 - это опкод инструкции call. Найдем значение аргумента этой команды: (gdb) x/xw inter_module_register+35 0xc01a57d7 : 0x0001924f Теперь если мы сложим текущий адрес 0xc01a57d6, смещение 0x0001924f и 5 байт команды, то получим искомый адрес функции kmalloc - 0xc01bea2a.

На этом завершим теоретические выкладки и, используя вышеприведенную методику, осуществим перехват системного вызова sys_mkdir.

6. Пример перехвата средствами /dev/kmem

/* source 6.0 */ #include #include #include #include #include #include #include #include /* Номер системного вызова для перехвата */ #define _SYS_MKDIR_ 39 #define KMEM_FILE "/dev/kmem" #define MAX_SYMS 4096 /* Описание формата регистра IDTR */ struct { unsigned short limit; unsigned int base; } __attribute__ ((packed)) idtr; /* Описание формата шлюза прерывания таблицы IDT */ struct { unsigned short off1; unsigned short sel; unsigned char none, flags; unsigned short off2; } __attribute__ ((packed)) idt; /* Описание структуры для функции get_kmalloc */ struct kma_struc { ulong (*kmalloc) (uint, int); // - адрес функции kmalloc int size; // - размер памяти для выделения int flags; // - флаг, для ядeр >2.4.9 = 0x1f0 (GFP) ulong mem; } __attribute__ ((packed)) kmalloc; /* Функция которая только выделяет блок памяти в адресном пространстве ядра */ int get_kmalloc(struct kma_struc *k) { k->mem = k->kmalloc(k->size, k->flags); return 0; } /* Функция которая возвращает адрес функции (нужно для поиска kmalloc) */ ulong get_sym(char *n) { struct kernel_sym tab; int numsyms; int i; numsyms = get_kernel_syms(NULL); if (numsyms > MAX_SYMS || numsyms < 0) return 0; get_kernel_syms(tab); for (i = 0; i < numsyms; i++) { if (!strncmp(n, tab[i].name, strlen(n))) return tab[i].value; } return 0; } /* Наша новая системная функция, ничего не делает;) */ int new_mkdir(const char *path) { return 0; } /* Читает из /dev/kmem с offset size данных в buf */ static inline int rkm(int fd, uint offset, void *buf, uint size) { if (lseek(fd, offset, 0) != offset){ printf("lseek err\n"); return 0; } if (read(fd, buf, size) != size) return 0; return size; } /* Аналогично, но только пишет в /dev/kmem */ static inline int wkm(int fd, uint offset, void *buf, uint size) { if (lseek(fd, offset, 0) != offset) return 0; if (write(fd, buf, size) != size) return 0; return size; } /* Читает из /dev/kmem данные размером 4 байта */ static inline int rkml(int fd, uint offset, ulong *buf) { return rkm(fd, offset, buf, sizeof(ulong)); } /* Аналогично, но только пишет */ static inline int wkml(int fd, uint offset, ulong buf) { return wkm(fd, offset, &buf, sizeof(ulong)); } /* Функция для получения адреса sys_call_table */ ulong get_sct(int kmem) { ulong sys_call_off; // - адрес обработчика // прерывания int $0x80 (функция system_call) char *p; char sc_asm; asm("sidt %0" : "=m" (idtr)); if (!rkm(kmem, idtr.base+(8*0x80), &idt, sizeof(idt))) return 0; sys_call_off = (idt.off2 << 16) | idt.off1; if (!rkm(kmem, sys_call_off, &sc_asm, 128)) return 0; p = (char *)memmem(sc_asm, 128, "\xff\x14\x85", 3) + 3; printf("call for sys_call_table at %08x\n",p); if (p) return *(ulong *)p; return 0; } /* Функция для определения адреса функции kmalloc */ ulong get_kma(ulong pgoff) { uint i; unsigned char buf, *p, *p1; int kmemz; ulong ret; ret = get_sym("kmalloc"); if (ret) { printf("\nZer gut!\n"); return ret; } kmemz = open("/dev/kmem", O_RDONLY); if (kmemz < 0) return 0; for (i = pgoff+0x100000; i < (pgoff + 0x1000000); i += 0x10000){ if (!rkm(kmemz, i, buf, sizeof(buf))) return 0; p1=(char *)memmem(buf,sizeof(buf),"\x68\xf0\x01\x00",4); if(p1) { p=(char *)memmem(p1+4,sizeof(buf),"\xe8",1)+1; if (p) { close(kmemz); return *(unsigned long *)p+i+(p-buf)+4; } } } close(kmemz); return 0; } int main() { int kmem; // !! - пустые, нужно подставить ulong get_kmalloc_size; // - размер функции get_kmalloc !! ulong get_kmalloc_addr; // - адрес функции get_kmalloc !! ulong new_mkdir_size; // - размер функции-перехватчика!! ulong new_mkdir_addr; // - адрес функции-перехватчика!! ulong sys_mkdir_addr; // - адрес системного вызова sys_mkdir ulong page_offset; // - нижняя граница адресного // пространства ядра ulong sct; // - адрес таблицы sys_call_table ulong kma; // - адрес функции kmalloc unsigned char tmp; kmem = open(KMEM_FILE, O_RDWR, 0); if (kmem < 0) return 0; sct = get_sct(kmem); page_offset = sct & 0xF0000000; kma = get_kma(page_offset); printf("OK\n" "page_offset\t\t:\t0x%08x\n" "sys_call_table\t:\t0x%08x\n" "kmalloc()\t\t:\t0x%08x\n", page_offset,sct,kma); /* Найдем адрес sys_mkdir */ if (!rkml(kmem, sct+(_SYS_MKDIR_*4), &sys_mkdir_addr)) { printf("Cannot get addr of %d syscall\n", _SYS_MKDIR_); perror("er: "); return 1; } /* Сохраним первые N байт вызова sys_mkdir */ if (!rkm(kmem, sys_mkdir_addr, tmp, get_kmalloc_size)) { printf("Cannot save old %d syscall!\n", _SYS_MKDIR_); return 1; } /* Перепишем первые N байт, функцией get_kmalloc */ if (!wkm(kmem, sys_mkdir_addr,(void *)get_kmalloc_addr, get_kmalloc_size)) { printf("Can"t overwrite our syscall %d!\n",_SYS_MKDIR_); return 1; } kmalloc.kmalloc = (void *) kma; //- адрес функции kmalloc kmalloc.size = new_mkdir_size; //- размер запращевоемой // памяти (размер функции-перехватчика new_mkdir) kmalloc.flags = 0x1f0; //- спецификатор GFP /* Выполним сис. вызов sys_mkdir, тем самым выполним нашу функцию get_kmalloc */ mkdir((char *)&kmalloc,0); /* Востановим оригинальный вызов sys_mkdir */ if (!wkm(kmem, sys_mkdir_addr, tmp, get_kmalloc_size)) { printf("Can"t restore syscall %d !\n",_SYS_MKDIR_); return 1; } if (kmalloc.mem < page_offset) { printf("Allocated memory is too low (%08x < %08x)\n", kmalloc.mem, page_offset); return 1; } /* Оторбразим результаты */ printf("sys_mkdir_addr\t\t:\t0x%08x\n" "get_kmalloc_size\t:\t0x%08x (%d bytes)\n\n" "our kmem region\t\t:\t0x%08x\n" "size of our kmem\t:\t0x%08x (%d bytes)\n\n", sys_mkdir_addr, get_kmalloc_size, get_kmalloc_size, kmalloc.mem, kmalloc.size, kmalloc.size); /* Разместим в пространстве ядра наш новый сис. вызво */ if(!wkm(kmem, kmalloc.mem, (void *)new_mkdir_addr, new_mkdir_size)) { printf("Unable to locate new system call !\n"); return 1; } /* Перепишем таблицу sys_call_table на наш новый вызов */ if(!wkml(kmem, sct+(_SYS_MKDIR_*4), kmalloc.mem)) { printf("Eh ..."); return 1; } return 1; } /* EOF */ Скомпилируем полученый код и определим адреса и размеры функций get_kmalloc и new_mkdir. Запускать полученое творение рано! Для вычисления адресов и размеров воспользуемся утилитой objdump: # gcc -o src-6.0 src-6.0.c # objdump -x ./src-6.0 > dump Откроем файл dump и найдем интересующие нас данные: 080485a4 g F .text 00000032 get_kmalloc 080486b1 g F .text 0000000a new_mkdir Теперь внесем эти значениея в нашу программу: ulong get_kmalloc_size=0x32; ulong get_kmalloc_addr=0x080485a4 ; ulong new_mkdir_size=0x0a; ulong new_mkdir_addr=0x080486b1; Теперь перекомпилируем программу. Запустив его на выполнение мы осуществим перехват системного вызова sys_mkdir. Все обращения к вызову sys_mkdir будут теперь обслуживаться функцией new_mkdir.

End Of Paper/EOP

Работоспособность кода из всех разделов была проверена на ядре 2.4.22. При подготовки доклада были использованы материалы сайта

О многом - молвил Морж,- пришла пора поговорить.
Л. Кэролл (Цитата по книге Б. Страустрапа)

Вместо введения.

По теме внутреннего устройства ядра Linux в общем, его различных подсистемах и о системных вызовах в частности, было писано и переписано уже порядком. Наверно, каждый уважающий себя автор должен хоть раз об этом написать, подобно тому, как каждый уважающий себя программист обязательно должен написать свой собственный файл-менеджер:) Хотя я и не являюсь профессиональным IT-райтером, да и вообще, делаю свои пометки исключительно для своей пользы в первую очередь, чтобы не забыть изученное слишком быстро. Но, если уж кому-то пригодятся мои путевые заметки, конечно, буду только рад. Ну и вообще, кашу маслом не испортишь, поэтому, может даже мне и удастся написать или описать что-то такое, о чём никто не удосужился упомянуть.

Теория. Что такое системные вызовы?

Когда непосвящённым объясняют, что такое ПО (или ОС), то говорят обычно следующее: компьютер сам по себе - это железяка, а вот софт - это то, благодаря чему от этой железяки можно получить какую-то пользу. Грубовато, конечно, но в целом, в чём-то верно. Так же я сказал бы наверно об ОС и системных вызовах. На самом деле, в разных ОС системные вызовы могут быть реализованы по-разному, может розниться число этих самых вызовов, но так или иначе, в том или ином виде механизм системных вызовов есть в любой ОС. Каждый день пользователь явно или не явно работает с файлами. Он конечно может явно открыть файл для редактирования в своём любимом MS Word"е или Notepad"е, а может и просто запустить игрушку, исполняемый образ которой, к слову, тоже хранится в файле, который, в свою очередь, должен открыть и прочитать загрузчик исполняемых файлов. В свою очередь игрушка также может открывать и читать десятки файлов в процессе своей работы. Естественно, файлы можно не только читать, но и писать (не всегда, правда, но здесь речь не о разделении прав и дискретном доступе:)). Всем этим заведует ядро (в микроядерных ОС ситуация может отличаться, но мы сейчас будем ненавязчиво клониться к объекту нашего обсуждения - Linux, поэтому проигнорируем этот момент). Само по себе порождение нового процесса - это также услуга, предоставляемая ядром ОС. Всё это замечательно, как и то, что современные процессоры работают на частотах гигагерцевых диапазонов и состоят из многих миллионов транзисторов, но что дальше? Да то, что если бы небыло некого механизма, с помощью которого пользовательские приложения могли выполнять некоторые достаточно обыденные и, в то же время, нужные вещи (на самом деле, эти тривиальные действия при любом раскладе выполняются не пользовательским приложением, а ядром ОС - авт. ), то ОС была просто вещью в себе - абсолютно бесполезной или же напротив, каждое пользовательское приложение само по себе должно было бы стать операционной системой, чтобы самостоятельно обслуживать все свои нужды. Мило, не правда ли?

Таким образом, мы подошли к определению системного вызова в первой аппроксимации: системный вызов - это некая услуга, которую ядро ОС оказывает пользовательскому приложению по запросу последнего. Такой услугой может быть уже упомянутое открытие файла, его создание, чтение, запись, создание нового процесса, получение идентификатора процесса (pid), монтирование файловой системы, останов системы, наконец. В реальной жизни системных вызовов гораздо больше, нежели здесь перечислено.

Как выглядит системный вызов и что из себя представляет? Ну, из того, что было сказано выше, становится ясно, что системный вызов - это подпрограмма ядра, имеющая соответственный вид. Те, кто имел опыт программирования под Win9х/DOS, наверняка помнят прерывание int 0x21 со всеми (или хотя бы некоторыми) его многочисленными функциями. Однако, есть одна небольшая особенность, касающаяся всех системных вызовов Unix. По соглашению функция, реализующая системный вызов, может принимать N аргументов или не принимать их вовсе, но так или иначе, функция должна возвращать значение типа int. Любое неотрицательное значение трактуется, как успешное выполнение функции системного вызова, а стало быть и самого системного вызова. Значение меньше нуля является признаком ошибки и одновременно содержит код ошибки (коды ошибок определяются в заголовках include/asm-generic/errno-base.h и include/asm-generic/errno.h). В Linux шлюзом для системных вызовов до недавнего времени было прерывание int 0x80, в то время, как в Windows (вплоть до версии XP Service Pack 2, если не ошибаюсь) таким шлюзом является прерывание 0x2e. Опять же, в ядре Linux, до недавнего времени все системные вызовы обрабатывались функцией system_call(). Однако, как выяснилось позднее, классический механизм обрабатки системных вызовов через шлюз 0x80 приводит к существенному падению производительности на процессорах Intel Pentium 4. Поэтому на смену классическому механизму пришёл метод виртуальных динамических разделяемых объектов (DSO - динамический разделяемый объектный файл. Не ручаюсь за правильный перевод, но DSO, это то, что пользователям Windows известно под названием DLL - динамически загружаемая и компонуемая библиотека) - VDSO. В чём отличие нового метода от классического? Для начала разберёмся с классическим методом, работающим через гейт 0x80.

Классический механизм обслуживания системных вызовов в Linux.

Прерывания в архитектуре х86.

Как было сказано выше, ранее для обслуживания запросов пользовательских приложений использовался шлюз 0x80 (int 0x80). Работа системы на базе архитектуры IA-32 управляется прерываниями (строго говоря, это касается вообще всех систем на базе x86). Когда происходит некое событие (новый тик таймера, какая-либо активность на некотором устройстве, ошибки - деление на ноль и пр.), генерируется прерывание. Прерывание (interrupt) названо так, потому что оно, как правило, прерывает нормальный ход выполнения кода. Прерывания принято подразделять на аппаратные и программные (hardware and software interrupts). Аппаратные прерывания - это прерывания, которые генерируются системными и периферическими устройствами. При возникновении необходимости какого-то устройства привлечь к себе внимание ядра ОС оно (устройство) генерирует сигнал на своей линии запроса прерывания (IRQ - Interrupt ReQuest line). Это приводит к тому, что на определённых входах процессора формируется соответствующий сигнал, на основе которого процессор и принимает решение прервать выполнение потока инструкций и передать управление на обработчик прерывания, который уже выясняет, что произошло и что необходимо сделать. Аппаратные прерывания асинхронны по своей природе. Это значит, что прерывание может возникнуть в любое время. Кроме периферийных устройств, сам процессор может вырабатывать прерывания (или, точнее, аппаратные исключения - Hardware Exceptions - например, уже упомянутое деление на ноль). Делается это с тем, чтобы уведомить ОС о возникновении нештатной ситуации, чтобы ОС могла предпринять некое действие в ответ на возникновение такой ситуации. После обработки прерывания процессор возвращается к выполнению прерванной программы. Прерывание может быть инициировано пользовательским приложением. Такое прерывание называют программным. Программные прерывания, в отличие от аппаратных, синхронны. Т.е., при вызове прерывания, вызвавший его код приостанавливается, пока прерывание не будет обслужено. При выходе из обработчика прерывания происходит возврат по дальнему адресу, сохранённому ранее (при вызове прерывания) в стеке, на следующую инструкцию после инструкции вызова прерывания (int). Обработчик прерывания - это резидентный (постоянно находящийся в памяти) участок кода. Как правило, это небольшая программа. Хотя, если мы будем говорить о ядре Linux, то там обработчик прерывания не всегда такой уж маленький. Обработчик прерывания определяется вектором. Вектор - это ни что иное, как адрес (сегмент и смещение) начала кода, который должен обрабатывать прерывания с данным индексом. Работа с прерываниями существенно отличается в реальном (Real Mode) и защищённом (Protected Mode) режиме работы процессора (напомню, что здесь и далее мы подразумеваем процессоры Intel и совместимые с ними). В реальном (незащищённом) режиме работы процессора обработчики прерываний определяются их векторами, которые хранятся всегда в начале памяти выборка нужного адреса из таблицы векторов происходит по индексу, который также является номером прерывания. Перезаписав вектор с определённым индексом можно назначить прерыванию свой собственный обработчик.

В защищённом режиме обработчики прерываний (шлюзы, гейты или вентили) больше не определяются с помощью таблицы векторов. Вместо этой таблицы используется таблица вентилей или, правильнее, таблица прерываний - IDT (Interrupt Descriptors Table). Эта таблица формируется ядром, а её адрес хранится в регистре процессора idtr. Данный регистр недоступен напрямую. Работа с ним возможна только при помощи инструкций lidt/sidt. Первая из них (lidt) загружает в регистр idtr значение, указанное в операнде и являющееся базовым адресом таблицы дексрипторов прерываний, вторая (sidt) - сохраняет адрес таблицы, находящийся в idtr, в указанный операнд. Так же, как происходит выборка информации о сегменте из таблицы дескрипторов по селектору, происходит и выборка дескриптора сегмента, обслуживающего прерывание в защищённом режиме. Защита памяти поддерживается процессорами Intel начиная с CPU i80286 (не совсем в том виде, в каком она представлена сейчас, хотя бы потому, что 286 был 16-разрядным процессором - поэтому Linux не может работать на этих процессорах) и i80386, а посему процессор самостоятельно производит все необходимые выборки и, стало быть, сильно углубляться во все тонкости защищённого режима (а именно в защищённом режиме работает Linux) мы не будем. К сожалению, ни время, ни возможности не позволяют нам остановиться надолго на механизме обработки прерываний в защищённом режиме. Да это и не было целью при написании данной статьи. Все сведения, приводимые здесь касательно работы процессоров семейства х86 довольно поверхностны и приводятся лишь для того, чтобы помочь немного лучше понять механизм работы системных вызовов ядра. Кое-что можно узнать непосредственно из кода ядра, хотя, для полного понимания происходящего, желательно всё же ознакомиться с принципами работы защищённого режима. Участок кода, который заполняет начальными значениями (но не устанавливает!) IDT, находится в arch/i386/kernel/head.S: /* * setup_idt * * sets up a idt with 256 entries pointing to * ignore_int, interrupt gates. It doesn"t actually load * idt - that can be done only after paging has been enabled * and the kernel moved to PAGE_OFFSET. Interrupts * are enabled elsewhere, when we can be relatively * sure everything is ok. * * Warning: %esi is live across this function. */ 1.setup_idt: 2. lea ignore_int,%edx 3. movl $(__KERNEL_CS << 16),%eax 4. movw %dx,%ax /* selector = 0x0010 = cs */ 5. movw $0x8E00,%dx /* interrupt gate - dpl=0, present */ 6. lea idt_table,%edi 7. mov $256,%ecx 8.rp_sidt: 9. movl %eax,(%edi) 10. movl %edx,4(%edi) 11. addl $8,%edi 12. dec %ecx 13. jne rp_sidt 14..macro set_early_handler handler,trapno 15. lea \handler,%edx 16. movl $(__KERNEL_CS << 16),%eax 17. movw %dx,%ax 18. movw $0x8E00,%dx /* interrupt gate - dpl=0, present */ 19. lea idt_table,%edi 20. movl %eax,8*\trapno(%edi) 21. movl %edx,8*\trapno+4(%edi) 22..endm 23. set_early_handler handler=early_divide_err,trapno=0 24. set_early_handler handler=early_illegal_opcode,trapno=6 25. set_early_handler handler=early_protection_fault,trapno=13 26. set_early_handler handler=early_page_fault,trapno=14 28. ret Несколько замечаний по коду: приведённый код написан на разновидности ассемблера AT&T, поэтому Ваше знание ассемблера в его привычной интеловской нотации может только сбить с толку. Самое основное отличие в порядке операндов. Если для интеловской нотации определён порядок - "аккумулятор" < "источник", то для ассемблера AT&T порядок прямой. Регистры процессора, как правило, должны иметь префикс "%", непосредственные значения (константы) префиксируются символом доллара "$". Синтаксис AT&T традиционно используется в Un*x-системах.

В приведённом примере в строках 2-4 устанавливается адрес обработчика всех прерываний по умолчанию. Обработчиком по умолчанию является функция ignore_int, которая ничего не делает. Наличие такой заглушки необходимо для корректной обработки всех прерываний на данном этапе, так как других ещё просто нет (правда, ловушки (traps) устанавливаются немного ниже по коду - о ловушках см. Intel Architecture Manual Reference или что-то подобное, здесь мы не будем касаться ловушек). В строке 5 устанавливается тип вентиля. В строке 6 мы загружаем в индексный регистр адрес нашей таблицы IDT. Таблица должна содержать 255 записей, по 8 байт каждая. В строках 8-13 мы заполняем всю таблицу одними и теми же значениями, установленными ранее в регистрах eax и edx - т.е., это вентиль прерывания, ссылающийся на обработчик ignore_int. Чуть ниже мы определяем макрос для установки ловушек (traps) - строки 14-22. В строках 23-26 используя вышеопределённый макрос мы устанавливаем ловушки для следующих исключений: early_divide_err - деление на ноль (0), early_illegal_opcode - неизвестная инструкция процессора (6), early_protection_fault - сбой защиты памяти (13), early_page_fault - отказ страничной трансляции (14). В скобках приведены номера "прерываний", генерируемые при возникновении соответствующей нештатной ситуации. Перед проверкой типа процессора в arch/i386/kernel/head.S таблица IDT устанавливается вызовом setup_idt: /* * start system 32-bit setup. We need to re-do some of the things done * in 16-bit mode for the "real" operations. */ 1. call setup_idt ... 2. call check_x87 3. lgdt early_gdt_descr 4. lidt idt_descr После выяснения типа (со)процессора и проведения всех подготовительных действий в строках 3 и 4 мы загружаем таблицы GDT и IDT, которые будут использоваться на самых первых порах работы ядра.

Системные вызовы и int 0x80.

От прерываний вернёмся обратно к системным вызовам. Итак, что необходимо, чтобы обслужить процесс, который запрашивает какую-то услугу? Для начала, необходимо перейти из кольца 3 (уровень привелегий CPL=3) на наиболее привелигированный уровень 0 (Ring 0, CPL=0), т.к. код ядра расположен в сегменте с наивысшими привелегиями. Кроме того, необходим код обработчика, который обслужит процесс. Именно для этого и используется шлюз 0x80. Хотя системных вызовов довольно много, для всех них используется единая точка входа - int 0x80. Сам обработчик устанавливается при вызове функции arch/i386/kernel/traps.c::trap_init(): void __init trap_init(void) { ... set_system_gate(SYSCALL_VECTOR,&system_call); ... } Нас в trap_init() больше всего интересует эта строка. В этом же файле выше можно посмотреть на код функции set_system_gate(): static void __init set_system_gate(unsigned int n, void *addr) { _set_gate(n, DESCTYPE_TRAP | DESCTYPE_DPL3, addr, __KERNEL_CS); } Здесь видно, что вентиль для прерывания 0х80 (а именно это значение определено макросом SYSCALL_VECTOR - можете поверить наслово:)) устанавливается как ловушка (trap) с уровнем привелегий DPL=3 (Ring 3), т.е. это прерывание будет отловлено при вызове из пространства пользователя. Проблема с переходом из Ring 3 в Ring 0 т.о. решена. Функция _set_gate() определена в заголовочном файле include/asm-i386/desc.h . Для особо любопытных ниже приведён код, без пространных объяснений, впрочем: static inline void _set_gate(int gate, unsigned int type, void *addr, unsigned short seg) { __u32 a, b; pack_gate(&a, &b, (unsigned long)addr, seg, type, 0); write_idt_entry(idt_table, gate, a, b); } Вернёмся к функции trap_init(). Она вызывается из функции start_kernel() в init/main.c . Если посмотреть на код trap_init(), то видно, что эта функция переписывает некоторые значения таблицы IDT заново - обработчики, которые использовались на ранних стадиях инициализации ядра (early_page_fault, early_divide_err, early_illegal_opcode, early_protection_fault), заменяются на те, что будут использоваться уже в процессе работы ядра. Итак, мы практически добрались до сути и уже знаем, что все системные вызовы обрабатываются единообразно - через шлюз int 0x80. В качестве обработчика для int 0x80, как опять же видно из приведённого выше куска кода arch/i386/kernel/traps.c::trap_init(), устанавливается функция system_call().

system_call().

Код функции system_call() находится в файле arch/i386/kernel/entry.S и выглядит следующим образом: # system call handler stub ENTRY(system_call) RING0_INT_FRAME # can"t unwind into user space anyway pushl %eax # save orig_eax CFI_ADJUST_CFA_OFFSET 4 SAVE_ALL GET_THREAD_INFO(%ebp) # system call tracing in operation / emulation /* Note, _TIF_SECCOMP is bit number 8, and so it needs testw and not testb */ testw $(_TIF_SYSCALL_EMU|_TIF_SYSCALL_TRACE|_TIF_SECCOMP|_TIF_SYSCALL_AUDIT),TI_flags(%ebp) jnz syscall_trace_entry cmpl $(nr_syscalls), %eax jae syscall_badsys syscall_call: call *sys_call_table(,%eax,4) movl %eax,PT_EAX(%esp) # store the return value ... Код приведён не полностью. Как видно, сперва system_call() настраивает стек для работы в Ring 0, сохраняет значение, переданное ей через eax в стек, сохраняет все регистры также в стек, получает данные о вызывающем потоке и проверяет, не выходит ли переданное значение-номер системного вызова за пределы таблицы системных вызовов и затем, наконец, пользуясь значением, переданным в eax в качестве аргумента, system_call() осуществляет переход на настоящий обработчик системного вывода, исходя из того, на какой элемент таблицы ссылается индекс в eax. Теперь вспомните старую добрую таблицу векторов прерываний из реального режима. Ничего не напоминает? В реальности, конечно, всё несколько сложнее. В частности, системный вызов должен скопировать результаты из стека ядра в стек пользователя, передать код возврата и ещё некоторые вещи. В том случае, когда аргумент, указанный в eax не ссылается на существующий системный вызов (значение выходит за диапазон), происходит переход на метку syscall_badsys. Здесь в стек по смещению, по которому должно находиться значение eax, заносится значение -ENOSYS - системный вызов не реализован. На этом выполнение system_call() завершается.

Таблица системных вызовов находится в файле arch/i386/kernel/syscall_table.S и имеет достаточно простой вид: ENTRY(sys_call_table) .long sys_restart_syscall /* 0 - old "setup()" system call, used for restarting */ .long sys_exit .long sys_fork .long sys_read .long sys_write .long sys_open /* 5 */ .long sys_close .long sys_waitpid .long sys_creat ... Иными словами, вся таблица являет собой ничто иное, как массив адресов функций, расположенных в порядке следования номеров системных вызовов, которые эти функции обслуживают. Таблица - обычный массив двойных машинных слов (или 32-разрядных слов - кому как больше нравится). Код части функций, обслуживающих системные вызовы, находится в платформно-зависимой части - arch/i386/kernel/sys_i386.c, а часть, не зависящая от платформы - в kernel/sys.c .

Вот так обстоит дело с системными вызовами и вентилем 0x80.

Новый механизм обработки системных вызовов в Linux. sysenter/sysexit.

Как упоминалось, достаточно быстро выяснилось, что использование традиционного способа обработки системных вызовов на основе гейта 0х80 пиводит к потере производительности на процессорах Intel Pentium 4. Поэтому Линус Торвальдс реализовал в ядре новый механизм, основанный на инструкциях sysenter/sysexit и призванный повысить производительность ядра на машинах, оснащённых процессором Pentium II и выше (именно с Pentium II+ процессоры Intel поддерживают упомянутые инструкции sysenter/sysexit). В чём суть нового механизма? Как ни странно, но суть осталась та же. Изменилось исполнение. Согласно документации Intel инструкция sysenter является частью механизма "быстрых системных вызовов". В частности, эта инструкция оптимизирована для быстрого перехода с одного уровня привелегий на другой. Если точнее, то она ускоряет переход в кольцо 0 (Ring 0, CPL=0). При этом, операционная система должна подготовить процессор к использовании инструкции sysenter. Такая настройка осуществляется единожды при загрузке и инициализации ядра ОС. При вызове sysenter устанавливает регистры процессора согласно машинно-зависимых регистров, ранее установленных ОС. В частности, устанавливаются сегментный регистр и регистр указателя инструкций - cs:eip, а также сегмент стека и указатель вершины стека - ss, esp. Переход на новый сегмент кода и смещение осуществляется из кольца 3 в 0.

Инструкция sysexit выполняют обратные действия. Она производит быстрый переход с уровня привелегий 0 на 3-й (CPL=3). При этом регистр сегмента кода устанавливается в 16 + значение сегмента cs, сохранённое в машинно-зависимом регистре процессора. В регистр eip заносится содержимое регистра edx. В ss заносится сумма 24 и значения cs, занесённое ОС ранее в машинно-зависимый регистр процессора при подготовке контекста для работы инструкции sysenter. В esp заносится содержимое регистра ecx. Значения, необходимые для работы инструкций sysenter/sysexit хранятся по следующим адресам:

  1. SYSENTER_CS_MSR 0х174 - сегмент кода, куда заносится значение сегмента, в котором находится код обработчика системного вызова.
  2. SYSENTER_ESP_MSR 0х175 - указатель на вершину стека для обработчика системного вызова.
  3. SYSENTER_EIP_MSR 0х176 - указатель на смещение внутри сегмента кода. Указывает на начало кода обработчика системных вызовов.
Данные адреса ссылаются на модельно-зависимые регистры, которые не имеют имён. Значения записываются в модельно зависимые регистры с помощью инструкции wrmsr, при этом edx:eax должны содержать страшую и младшую части 64-битного машинного слова соответственно, а в ecx должен быть занесён адрес ригистра, в который будет произведена запись. В Linux адреса модельно-зависимых регистров определяются в заголовочном файле include/asm-i368/msr-index.h следующим образом (до версии 2.6.22 как минимум они определялись в заголовочном файле include/asm-i386/msr.h, напомню, что мы рассматриваем механизм системных вызовов на примере ядра Linux 2.6.22): #define MSR_IA32_SYSENTER_CS 0x00000174 #define MSR_IA32_SYSENTER_ESP 0x00000175 #define MSR_IA32_SYSENTER_EIP 0x00000176 Код ядра, ответственный за установку модельно-зависимых регистров, находится в файле arch/i386/sysenter.c и выглядит следующим образом: 1. void enable_sep_cpu(void) { 2. int cpu = get_cpu(); 3. struct tss_struct *tss = &per_cpu(init_tss, cpu); 4. if (!boot_cpu_has(X86_FEATURE_SEP)) { 5. put_cpu(); 6. return; } 7. tss->x86_tss.ss1 = __KERNEL_CS; 8. tss->x86_tss.esp1 = sizeof(struct tss_struct) + (unsigned long) tss; 9. wrmsr(MSR_IA32_SYSENTER_CS, __KERNEL_CS, 0); 10. wrmsr(MSR_IA32_SYSENTER_ESP, tss->x86_tss.esp1, 0); 11. wrmsr(MSR_IA32_SYSENTER_EIP, (unsigned long) sysenter_entry, 0); 12. put_cpu(); } Здесь в переменную tss мы получаем адрес структуры, описывающей сегмент состояния задачи. TSS (Task State Segment) используется для описания контекста задачи и является частью механизма аппаратной поддержки многозадачности для архитектуры x86. Однако, Linux практически не использует аппаратное переключение контекста задач. Согласно документации Intel переключение на другую задачу производится либо путём выполнения инструкции межсегментного перехода (jmp или call), ссылающейся на сегмент TSS, либо на дескриптор вентиля задачи в GDT (LDT). Специальный регистр процессора, невидимый для программиста - TR (Task Register - регистр задачи) содержит селектор дескриптора задачи. При загрузке этого регистра также загружаются программно-невидимые регистры базы и лимита, связанные с TR.

Несмотря на то, что Linux не использует аппаратное переключение контекстов задач, ядро вынуждено отводить запись TSS для каждого процессора, установленного в системе. Это связано с тем, что когда процессор переключается из пользовательского режима в режим ядра, он извлекает из TSS адрес стека ядра. Кроме того, TSS необходим для управления доступом к портам ввода/вывода. TSS содержит карту прав доступа к портам. На основе этой карты становится возможным осуществлять контроль доступа к портам для каждого процесса, использующего инструкции in/out. Здесь tss->x86_tss.esp1 указывает на стек ядра. __KERNEL_CS естественно указывает на сегмент кода ядра. В качестве смещения-eip указывается адрес функции sysenter_entry().

Функция sysenter_entry() определена в файле arch/i386/kernel/entry.S и имеет такой вид: /* SYSENTER_RETURN points to after the "sysenter" instruction in the vsyscall page. See vsyscall-sysentry.S, which defines the symbol. */ # sysenter call handler stub ENTRY(sysenter_entry) CFI_STARTPROC simple CFI_SIGNAL_FRAME CFI_DEF_CFA esp, 0 CFI_REGISTER esp, ebp movl TSS_sysenter_esp0(%esp),%esp sysenter_past_esp: /* * No need to follow this irqs on/off section: the syscall * disabled irqs and here we enable it straight after entry: */ ENABLE_INTERRUPTS(CLBR_NONE) pushl $(__USER_DS) CFI_ADJUST_CFA_OFFSET 4 /*CFI_REL_OFFSET ss, 0*/ pushl %ebp CFI_ADJUST_CFA_OFFSET 4 CFI_REL_OFFSET esp, 0 pushfl CFI_ADJUST_CFA_OFFSET 4 pushl $(__USER_CS) CFI_ADJUST_CFA_OFFSET 4 /*CFI_REL_OFFSET cs, 0*/ /* * Push current_thread_info()->sysenter_return to the stack. * A tiny bit of offset fixup is necessary - 4*4 means the 4 words * pushed above; +8 corresponds to copy_thread"s esp0 setting. */ pushl (TI_sysenter_return-THREAD_SIZE+8+4*4)(%esp) CFI_ADJUST_CFA_OFFSET 4 CFI_REL_OFFSET eip, 0 /* * Load the potential sixth argument from user stack. * Careful about security. */ cmpl $__PAGE_OFFSET-3,%ebp jae syscall_fault 1: movl (%ebp),%ebp .section __ex_table,"a" .align 4 .long 1b,syscall_fault .previous pushl %eax CFI_ADJUST_CFA_OFFSET 4 SAVE_ALL GET_THREAD_INFO(%ebp) /* Note, _TIF_SECCOMP is bit number 8, and so it needs testw and not testb */ testw $(_TIF_SYSCALL_EMU|_TIF_SYSCALL_TRACE|_TIF_SECCOMP|_TIF_SYSCALL_AUDIT),TI_flags(%ebp) jnz syscall_trace_entry cmpl $(nr_syscalls), %eax jae syscall_badsys call *sys_call_table(,%eax,4) movl %eax,PT_EAX(%esp) DISABLE_INTERRUPTS(CLBR_ANY) TRACE_IRQS_OFF movl TI_flags(%ebp), %ecx testw $_TIF_ALLWORK_MASK, %cx jne syscall_exit_work /* if something modifies registers it must also disable sysexit */ movl PT_EIP(%esp), %edx movl PT_OLDESP(%esp), %ecx xorl %ebp,%ebp TRACE_IRQS_ON 1: mov PT_FS(%esp), %fs ENABLE_INTERRUPTS_SYSEXIT CFI_ENDPROC .pushsection .fixup,"ax" 2: movl $0,PT_FS(%esp) jmp 1b .section __ex_table,"a" .align 4 .long 1b,2b .popsection ENDPROC(sysenter_entry) Как и в случае с system_call() основная работа выполняется в строке call *sys_call_table(,%eax,4). Здесь вызывается конкретный обработчик системного вызова. Итак, видно, что принципиально изменилось мало. То обстоятельство, что вектор прерывания теперь забит в железо и процессор помогает нам быстрее перейти с одного уровня привелегий на другой меняет лишь некоторые детали исполнения при прежнем содержании. Правда, на этом изменения не заканчиваются. Вспомните, с чего начиналось повествование. В самом начале я упоминал уже о виртуальных разделяемых объектах. Так вот, если раньше реализация системного вызова, скажем, из системной библиотеки libc выглядела, как вызов прерывания (при том, что библиотека брала некоторые функции на себя, чтобы сократить число переключений контекстов), то теперь благодаря VDSO системный вызов может быть сделан практически напрямую, без участия libc. Он и ранее мог быть осуществлён напрямую, опять же, как прерывание. Но теперь вызов можно затребовать, как обычную функцию, экспортируемую из динамически компонуемой библиотеки (DSO). При загрузке ядро определяет, какой механизм должен и может быть использован для данной платформы. В зависимости от обстоятельств ядро устанавливает точку входа в функцию, выполняющую системный вызов. Далее, функция экспортируется в пользовательское пространство ввиде библиотеки linux-gate.so.1 . Библиотека linux-gate.so.1 физически не существует на диске. Она, если можно так выразиться, эмулируется ядром и существует ровно столько, сколько работает система. Если выполнить останов системы, подмонтировать корневую ФС из другой системы, то Вы не найдёте на корневой ФС остановленной системы этот файл. Собственно, Вы не сможете его найти даже на работающей системе. Физически его просто нет. Именно поэтому linux-gate.so.1 - это нечто иное, как VDSO - т.е. Virtual Dynamically Shared Object. Ядро отображает эмулируемую таким образом динамическую библиотеку в адресное пространство каждого процесса. Убедиться в этом несложно, если выполнить следующую команду: f0x@devel0:~$ cat /proc/self/maps 08048000-0804c000 r-xp 00000000 08:01 46 /bin/cat 0804c000-0804d000 rw-p 00003000 08:01 46 /bin/cat 0804d000-0806e000 rw-p 0804d000 00:00 0 ... b7fdf000-b7fe1000 rw-p 00019000 08:01 2066 /lib/ld-2.5.so bffd2000-bffe8000 rw-p bffd2000 00:00 0 ffffe000-fffff000 r-xp 00000000 00:00 0 Здесь самая последняя строка и есть интересующий нас объект: ffffe000-fffff000 r-xp 00000000 00:00 0 Из приведённого примера видно, что объект занимает в памяти ровно одну страницу - 4096 байт, практически на задворках адресного пространства. Проведём ещё один эксперимент: f0x@devel0:~$ ldd `which cat` linux-gate.so.1 => (0xffffe000) libc.so.6 => /lib/tls/i686/cmov/libc.so.6 (0xb7e87000) /lib/ld-linux.so.2 (0xb7fdf000) f0x@devel0:~$ ldd `which gcc` linux-gate.so.1 => (0xffffe000) libc.so.6 => /lib/tls/i686/cmov/libc.so.6 (0xb7e3c000) /lib/ld-linux.so.2 (0xb7f94000) f0x@devel0:~$ Здесь мы просто навскидку взяли два приложения. Видно, что библиотека отображается в адресное пространство процесса по одному и тому же постоянному адресу - 0xffffe000. Теперь попробуем посмотреть, что же такое хранится на этой странице памяти на самом деле...

Сделать дамп страницы памяти, где хранится разделяемый код VDSO, можно с помощью следующей программы: #include #include #include int main () { char* vdso = 0xffffe000; char* buffer; FILE* f; buffer = malloc (4096); if (!buffer) exit (1); memcpy (buffer, vdso, 4096); if (!(f = fopen ("test.dump", "w+b"))) { free (buffer); exit (1); } fwrite (buffer, 4096, 1, f); fclose (f); free (buffer); return 0; } Строго говоря, раньше это можно было сделать проще, с помощью команды dd if=/proc/self/mem of=test.dump bs=4096 skip=1048574 count=1 , но ядра начиная с версии 2.6.22 или, быть может, даже более ранней, больше не отображают память процесса в файл /proc/`pid`/mem. Этот файл, сохранён, очевидно, для совместимости, но не содержит более информации.

Скомпилируем и прогоним приведённую программу. Попробуем дизассемблировать полученный код: f0x@devel0:~/tmp$ objdump --disassemble ./test.dump ./test.dump: file format elf32-i386 Disassembly of section .text: ffffe400 <__kernel_vsyscall>: ffffe400: 51 push %ecx ffffe401: 52 push %edx ffffe402: 55 push %ebp ffffe403: 89 e5 mov %esp,%ebp ffffe405: 0f 34 sysenter ... ffffe40e: eb f3 jmp ffffe403 <__kernel_vsyscall+0x3> ffffe410: 5d pop %ebp ffffe411: 5a pop %edx ffffe412: 59 pop %ecx ffffe413: c3 ret ... f0x@devel0:~/tmp$ Вот он наш шлюз для системных вызовов, весь, как на ладони. Процесс (либо, системная библиотека libc), вызывая функцию __kernel_vsyscall попадает на адрес 0хffffe400 (в нашем случае). Далее, __kernel_vsyscall сохраняет в стеке пользовательского процесса содержимое регистров ecx, edx, ebp, О назначении регистров ecx и edx мы уже говорили ранее, в ebp используется позже для восстановления стека пользователя. Выполняется инструкция sysenter, "перехват прерывания" и, как следствие, очередной переход на sysenter_entry (см. выше). Инструкция jmp по адресу 0xffffe40e вставлена для перезапуска системного вызова с числом 6 аргументами (см. http://lkml.org/lkml/2002/12/18/). Код, размещаемый на странице, находится в файле arch/i386/kernel/vsyscall-enter.S (или arch/i386/kernel/vsyscall-int80.S для ловушки 0x80). Хотя я и нашёл, что адрес функции __kernel_vsyscall постоянный, но есть мнение, что это не так. Обычно, положение точки входа в __kernel_vsyscall() можно найти по вектору ELF-auxv используя параметр AT_SYSINFO. Вектор ELF-auxv содержит информацию, передаваемую процессу через стек при запуске и содержит различную информацию, нужную в процессе работы программы. Этот вектор в частности содержит переменные окружения процесса, аргументы, и проч..

Вот небольшой пример на С, как можно обратиться к функции __kernel_vsyscall напрямую: #include int pid; int main () { __asm ("movl $20, %eax \n" "call *%gs:0x10 \n" "movl %eax, pid \n"); printf ("pid: %d\n", pid); return 0; } Данный пример взят со страницы Manu Garg, http://www.manugarg.com . Итак, в приведённом примере мы делаем системный вызов getpid() (номер 20 или иначе __NR_getpid). Чтобы не лазить по стеку процесса в поисках переменной AT_SYSINFO воспользуемся тем обстоятельством, что системная библиотека libc.so при загрузке копирует значение переменной AT_SYSINFO в блок управления потоком (TCB - Thread Control Block). На этот блок информации, как правило, ссылается селектор в gs. Предполагаем, что по смещению 0х10 находится искомый параметр и делаем вызов по адресу, хранящемуся в %gs:$0x10.

Итоги.

На самом деле, практически, особого прироста производительности даже при поддержке на данной платформе FSCF (Fast System Call Facility) добиться не всегда возможно. Проблема в том, что так или иначе, процесс редко обращается напрямую к ядру. И для этого есть свои веские причины. Использование библиотеки libc позволяет гарантировать переносимость программы вне зависимости от версии ядра. И именно через стандартную системную библиотеку идёт большинство системных вызовов. Если даже Вы соберёте и установите самое последнее ядро, собранное для платформы, поддерживающей FSCF, это ещё не гарантия прироста производительности. Дело в том, что Ваша системная библиотека libc.so будет попрежнему использовать int 0x80 и справиться с этим можно лишь пересобрав glibc. Поддерживается ли в glibc вообще интерфейс VDSO и __kernel_vsyscall, я, честно признаться, на данный момент ответить затрудняюсь.

Ссылки.

Manu Garg"s page, http://www.manugarg.com
Scatter/Gather thoughts by Johan Petersson, http://www.trilithium.com/johan/2005/08/linux-gate/
Старый добрый Understanding the Linux kernel Куда же без него:)
Ну и конечно же, исходные коды Linux (2.6.22)



Если заметили ошибку, выделите фрагмент текста и нажмите Ctrl+Enter
ПОДЕЛИТЬСЯ:
NexxDigital - компьютеры и операционные системы